@sebspark/hyper-media
Advanced tools
+2
-2
@@ -207,7 +207,7 @@ //#region src/url.ts | ||
| links: { | ||
| ...resolvedLinks, | ||
| self: { | ||
| method: "GET", | ||
| href: resolveUrl(req) | ||
| } | ||
| }, | ||
| ...resolvedLinks | ||
| } | ||
@@ -214,0 +214,0 @@ }; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"index.mjs","names":[],"sources":["../src/url.ts","../src/cursor-list.ts","../src/entity.ts","../src/page-list.ts"],"sourcesContent":["import type { Request } from 'express'\n\n/**\n * Constructs a public-facing URL from the request context.\n *\n * Uses forwarded headers to build the correct public URL across proxy hops:\n * - `X-Forwarded-Host` — public hostname set by eg. Kong\n * - `X-Forwarded-Prefix` — path prefix accumulated across ALB hops\n *\n * When called without a url argument, returns the full self URL including\n * any query string. When called with a url argument, resolves it relative\n * to the request context:\n * - `/foo` — appended to the prefix only, ignoring the current path\n * - `./foo` — appended to prefix + current path\n * - `../foo` — walks up from prefix + current path\n * - `foo` — treated as `./foo`\n *\n * All URLs use implicit protocol (`//host/path`) so the client inherits\n * the protocol from its own context.\n *\n * @param req - The Express request.\n * @param url - Optional URL to resolve. Omit to get the self URL.\n * @returns A protocol-relative public-facing URL.\n *\n * @example\n * resolveUrl(req) // //api.example.com/trading/v1/orders?status=active\n * resolveUrl(req, '/health') // //api.example.com/trading/v1/health\n * resolveUrl(req, './detail') // //api.example.com/trading/v1/orders/detail\n * resolveUrl(req, '../ping') // //api.example.com/trading/v1/ping\n */\nexport const resolveUrl = (req: Request, url?: string): string => {\n const host =\n req.get('x-forwarded-host')?.split(',')[0].trim() ?? req.get('host') ?? ''\n\n const prefix = (req.get('x-forwarded-prefix') ?? '')\n .split(',')\n .map((s) => s.trim().replace(/\\/$/, ''))\n .join('')\n\n const originalUrl = req.originalUrl\n const originalPath = originalUrl.split('?')[0].replace(/\\/$/, '')\n const query = originalUrl.includes('?') ? `?${originalUrl.split('?')[1]}` : ''\n\n if (!url) {\n return `//${host}${prefix}${originalPath}${query}`\n }\n\n const resolved = resolveSegment(prefix, originalPath, url)\n return `//${host}${resolved}`\n}\n\n/**\n * Resolves a URL segment relative to the current prefix and path.\n *\n * @param prefix - The accumulated path prefix from X-Forwarded-Prefix.\n * @param originalUrl - The current request path, without query string.\n * @param url - The URL to resolve.\n * @returns A resolved absolute path.\n */\nconst resolveSegment = (\n prefix: string,\n originalUrl: string,\n url: string\n): string => {\n if (url.startsWith('/')) {\n // /foo → prefix + /foo\n return `${prefix}${url}`\n }\n\n if (url.startsWith('./')) {\n // ./foo → prefix + originalUrl + /foo\n return `${prefix}${originalUrl}/${url.slice(2)}`\n }\n\n if (url.startsWith('../')) {\n // ../foo → walk up from prefix + originalUrl\n return resolveParent(`${prefix}${originalUrl}`, url)\n }\n\n // Bare string treated as ./foo\n return `${prefix}${originalUrl}/${url}`\n}\n\n/**\n * Walks up the path hierarchy for each `../` segment, stopping at root.\n *\n * @param base - The full base path to walk up from.\n * @param url - The `../`-prefixed URL to resolve.\n * @returns A resolved absolute path.\n */\nconst resolveParent = (base: string, url: string): string => {\n const parts = base.split('/').filter(Boolean)\n let remaining = url\n\n while (remaining.startsWith('../')) {\n if (parts.length > 0) parts.pop()\n remaining = remaining.slice(3)\n }\n\n return `/${parts.join('/')}${parts.length > 0 ? '/' : ''}${remaining}`\n}\n","import type { Request } from 'express'\nimport type { CursorListEntity, CursorMeta, Entity, Link } from './types'\nimport { resolveUrl } from './url'\n\n/**\n * Builds a cursor navigation URL by merging the cursor and page size into the\n * current request's query params, replacing any existing cursor params.\n *\n * @param req - The Express request.\n * @param cursor - The cursor value to set.\n * @param cursorParam - Whether this is a next or prev cursor.\n * @param pageSize - Number of items per page.\n * @returns A path-relative URL with cursor and page_size query params merged in.\n */\nconst buildCursorUrl = (\n req: Request,\n cursor: string,\n cursorParam: 'next_cursor' | 'prev_cursor',\n pageSize: number\n): string => {\n const [path, queryString] = req.originalUrl.split('?')\n const params = new URLSearchParams(queryString)\n // Remove both cursor params before setting the new one to avoid duplicates\n params.delete('next_cursor')\n params.delete('prev_cursor')\n params.set(cursorParam, cursor)\n params.set('page_size', String(pageSize))\n return `${path}?${params.toString()}`\n}\n\n/**\n * Builds the first-page URL by stripping all cursor and page size params\n * while preserving any other query params such as filters.\n *\n * @param req - The Express request.\n * @returns A path-relative URL with cursor and page_size params removed.\n */\nconst buildFirstUrl = (req: Request): string => {\n const [path, queryString] = req.originalUrl.split('?')\n const params = new URLSearchParams(queryString)\n params.delete('next_cursor')\n params.delete('prev_cursor')\n params.delete('page_size')\n const query = params.toString()\n return query ? `${path}?${query}` : path\n}\n\n/**\n * Wraps a pre-mapped list of entities in a cursor-based pagination envelope.\n *\n * @param req - The Express request, used to construct public-facing URLs.\n * @param data - Pre-mapped entities, each wrapped with {@link toEntity}.\n * @param pageSize - Number of items per page.\n * @param nextCursor - Cursor for the next page. Omit or pass undefined if on the last page.\n * @param prevCursor - Cursor for the previous page. Omit or pass undefined if not supported or on the first page.\n * @returns A {@link CursorListEntity} with self, first, and optional next/prev links.\n *\n * @example\n * const entity = toCursorListEntity(\n * req,\n * orders.map((o) => toEntity(req, o, { self: `./orders/${o.id}` })),\n * 10,\n * 'cursor-abc',\n * 'cursor-xyz'\n * )\n */\nexport const toCursorListEntity = <T, E extends Entity<T>>(\n req: Request,\n data: E[],\n pageSize: number,\n nextCursor?: string | undefined,\n prevCursor?: string | undefined\n): CursorListEntity<T> => {\n const _meta: CursorMeta = {\n pageSize,\n ...(nextCursor && { nextCursor }),\n ...(prevCursor && { prevCursor }),\n }\n\n const links = {\n self: { method: 'GET', href: resolveUrl(req) } as Link,\n first: { method: 'GET', href: resolveUrl(req, buildFirstUrl(req)) } as Link,\n ...(nextCursor && {\n next: {\n method: 'GET',\n href: resolveUrl(\n req,\n buildCursorUrl(req, nextCursor, 'next_cursor', pageSize)\n ),\n } as Link,\n }),\n ...(prevCursor && {\n prev: {\n method: 'GET',\n href: resolveUrl(\n req,\n buildCursorUrl(req, prevCursor, 'prev_cursor', pageSize)\n ),\n } as Link,\n }),\n }\n\n return { data, _meta, links }\n}\n","import type { Request } from 'express'\nimport type { Entity, Link } from './types'\nimport { resolveUrl } from './url'\n\n/**\n * Normalises a mixed link record by converting string shorthands to full\n * {@link Link} objects with a default GET method.\n *\n * @param links - A record of links, either as strings or full {@link Link} objects.\n * @returns A record of full {@link Link} objects.\n */\nconst normaliseLinks = (\n links: Record<string, string | Link>\n): Record<string, Link> => {\n const result: Record<string, Link> = {}\n for (const [name, link] of Object.entries(links)) {\n result[name] =\n typeof link === 'string' ? { method: 'GET', href: link } : link\n }\n return result\n}\n\n/**\n * Wraps data in a hypermedia entity envelope with a self link and any\n * additional resolved links.\n *\n * The self link is always derived from the request and cannot be overridden.\n * All link hrefs are resolved against the public-facing URL constructed from\n * forwarded headers.\n *\n * String links are treated as GET requests:\n * ```\n * { parent: '/orders' }\n * // is equivalent to\n * { parent: { method: 'GET', href: '/orders' } }\n * ```\n *\n * @param req - The Express request, used to construct public-facing URLs.\n * @param data - The data to wrap.\n * @param links - Optional record of links, either as strings or full {@link Link} objects.\n * @returns An {@link Entity} with resolved links and a self link.\n *\n * @example\n * const entity = toEntity(req, order, {\n * parent: '/orders',\n * cancel: { method: 'DELETE', href: './cancel', title: 'Cancel order' },\n * })\n */\nexport const toEntity = <T>(\n req: Request,\n data: T,\n links: Record<string, string | Link> = {}\n): Entity<T> => {\n const resolvedLinks: Record<string, Link> = {}\n for (const [name, { method, href }] of Object.entries(\n normaliseLinks(links)\n )) {\n resolvedLinks[name] = { method, href: resolveUrl(req, href) }\n }\n\n return {\n data,\n links: {\n ...resolvedLinks,\n self: { method: 'GET', href: resolveUrl(req) },\n },\n }\n}\n","import type { Request } from 'express'\nimport type { Entity, Link, PageListEntity } from './types'\nimport { resolveUrl } from './url'\n\n/**\n * Wraps a pre-mapped list of entities in a page-based pagination envelope.\n *\n * prev is absent on the first page, next is absent on the last page.\n * All existing query params such as filters are preserved and merged with\n * pagination params.\n *\n * @param req - The Express request, used to construct public-facing URLs.\n * @param data - Pre-mapped entities, each wrapped with {@link toEntity}.\n * @param page - Current page number (1-based).\n * @param pageSize - Number of items per page.\n * @param total - Total number of items across all pages.\n * @returns A {@link PageListEntity} with self, first, last, and optional prev/next links.\n *\n * @example\n * const entity = toPageListEntity(\n * req,\n * orders.map((o) => toEntity(req, o, { self: `./orders/${o.id}` })),\n * page,\n * pageSize,\n * total\n * )\n */\nexport const toPageListEntity = <T, E extends Entity<T>>(\n req: Request,\n data: E[],\n page: number,\n pageSize: number,\n total: number\n): PageListEntity<T> => {\n const lastPage = Math.ceil(total / pageSize)\n\n const buildLink = (p: number): Link => ({\n method: 'GET',\n href: resolveUrl(req, buildPageUrl(req, p, pageSize)),\n })\n\n return {\n data,\n _meta: { page, pageSize, total },\n links: {\n self: { method: 'GET', href: resolveUrl(req) },\n first: buildLink(1),\n last: buildLink(lastPage),\n ...(page > 1 && { prev: buildLink(page - 1) }),\n ...(page < lastPage && { next: buildLink(page + 1) }),\n },\n }\n}\n\n/**\n * Builds a page navigation URL by merging the page and page size into the\n * current request's query params, preserving any existing params such as filters.\n *\n * @param req - The Express request.\n * @param page - The page number to navigate to.\n * @param pageSize - Number of items per page.\n * @returns A path-relative URL with page and page_size query params merged in.\n */\nconst buildPageUrl = (req: Request, page: number, pageSize: number): string => {\n const [path, queryString] = req.originalUrl.split('?')\n const params = new URLSearchParams(queryString)\n params.set('page', String(page))\n params.set('page_size', String(pageSize))\n return `${path}?${params.toString()}`\n}\n"],"mappings":"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"} | ||
| {"version":3,"file":"index.mjs","names":[],"sources":["../src/url.ts","../src/cursor-list.ts","../src/entity.ts","../src/page-list.ts"],"sourcesContent":["import type { Request } from 'express'\n\n/**\n * Constructs a public-facing URL from the request context.\n *\n * Uses forwarded headers to build the correct public URL across proxy hops:\n * - `X-Forwarded-Host` — public hostname set by eg. Kong\n * - `X-Forwarded-Prefix` — path prefix accumulated across ALB hops\n *\n * When called without a url argument, returns the full self URL including\n * any query string. When called with a url argument, resolves it relative\n * to the request context:\n * - `/foo` — appended to the prefix only, ignoring the current path\n * - `./foo` — appended to prefix + current path\n * - `../foo` — walks up from prefix + current path\n * - `foo` — treated as `./foo`\n *\n * All URLs use implicit protocol (`//host/path`) so the client inherits\n * the protocol from its own context.\n *\n * @param req - The Express request.\n * @param url - Optional URL to resolve. Omit to get the self URL.\n * @returns A protocol-relative public-facing URL.\n *\n * @example\n * resolveUrl(req) // //api.example.com/trading/v1/orders?status=active\n * resolveUrl(req, '/health') // //api.example.com/trading/v1/health\n * resolveUrl(req, './detail') // //api.example.com/trading/v1/orders/detail\n * resolveUrl(req, '../ping') // //api.example.com/trading/v1/ping\n */\nexport const resolveUrl = (req: Request, url?: string): string => {\n const host =\n req.get('x-forwarded-host')?.split(',')[0].trim() ?? req.get('host') ?? ''\n\n const prefix = (req.get('x-forwarded-prefix') ?? '')\n .split(',')\n .map((s) => s.trim().replace(/\\/$/, ''))\n .join('')\n\n const originalUrl = req.originalUrl\n const originalPath = originalUrl.split('?')[0].replace(/\\/$/, '')\n const query = originalUrl.includes('?') ? `?${originalUrl.split('?')[1]}` : ''\n\n if (!url) {\n return `//${host}${prefix}${originalPath}${query}`\n }\n\n const resolved = resolveSegment(prefix, originalPath, url)\n return `//${host}${resolved}`\n}\n\n/**\n * Resolves a URL segment relative to the current prefix and path.\n *\n * @param prefix - The accumulated path prefix from X-Forwarded-Prefix.\n * @param originalUrl - The current request path, without query string.\n * @param url - The URL to resolve.\n * @returns A resolved absolute path.\n */\nconst resolveSegment = (\n prefix: string,\n originalUrl: string,\n url: string\n): string => {\n if (url.startsWith('/')) {\n // /foo → prefix + /foo\n return `${prefix}${url}`\n }\n\n if (url.startsWith('./')) {\n // ./foo → prefix + originalUrl + /foo\n return `${prefix}${originalUrl}/${url.slice(2)}`\n }\n\n if (url.startsWith('../')) {\n // ../foo → walk up from prefix + originalUrl\n return resolveParent(`${prefix}${originalUrl}`, url)\n }\n\n // Bare string treated as ./foo\n return `${prefix}${originalUrl}/${url}`\n}\n\n/**\n * Walks up the path hierarchy for each `../` segment, stopping at root.\n *\n * @param base - The full base path to walk up from.\n * @param url - The `../`-prefixed URL to resolve.\n * @returns A resolved absolute path.\n */\nconst resolveParent = (base: string, url: string): string => {\n const parts = base.split('/').filter(Boolean)\n let remaining = url\n\n while (remaining.startsWith('../')) {\n if (parts.length > 0) parts.pop()\n remaining = remaining.slice(3)\n }\n\n return `/${parts.join('/')}${parts.length > 0 ? '/' : ''}${remaining}`\n}\n","import type { Request } from 'express'\nimport type { CursorListEntity, CursorMeta, Entity, Link } from './types'\nimport { resolveUrl } from './url'\n\n/**\n * Builds a cursor navigation URL by merging the cursor and page size into the\n * current request's query params, replacing any existing cursor params.\n *\n * @param req - The Express request.\n * @param cursor - The cursor value to set.\n * @param cursorParam - Whether this is a next or prev cursor.\n * @param pageSize - Number of items per page.\n * @returns A path-relative URL with cursor and page_size query params merged in.\n */\nconst buildCursorUrl = (\n req: Request,\n cursor: string,\n cursorParam: 'next_cursor' | 'prev_cursor',\n pageSize: number\n): string => {\n const [path, queryString] = req.originalUrl.split('?')\n const params = new URLSearchParams(queryString)\n // Remove both cursor params before setting the new one to avoid duplicates\n params.delete('next_cursor')\n params.delete('prev_cursor')\n params.set(cursorParam, cursor)\n params.set('page_size', String(pageSize))\n return `${path}?${params.toString()}`\n}\n\n/**\n * Builds the first-page URL by stripping all cursor and page size params\n * while preserving any other query params such as filters.\n *\n * @param req - The Express request.\n * @returns A path-relative URL with cursor and page_size params removed.\n */\nconst buildFirstUrl = (req: Request): string => {\n const [path, queryString] = req.originalUrl.split('?')\n const params = new URLSearchParams(queryString)\n params.delete('next_cursor')\n params.delete('prev_cursor')\n params.delete('page_size')\n const query = params.toString()\n return query ? `${path}?${query}` : path\n}\n\n/**\n * Wraps a pre-mapped list of entities in a cursor-based pagination envelope.\n *\n * @param req - The Express request, used to construct public-facing URLs.\n * @param data - Pre-mapped entities, each wrapped with {@link toEntity}.\n * @param pageSize - Number of items per page.\n * @param nextCursor - Cursor for the next page. Omit or pass undefined if on the last page.\n * @param prevCursor - Cursor for the previous page. Omit or pass undefined if not supported or on the first page.\n * @returns A {@link CursorListEntity} with self, first, and optional next/prev links.\n *\n * @example\n * const entity = toCursorListEntity(\n * req,\n * orders.map((o) => toEntity(req, o, { self: `./orders/${o.id}` })),\n * 10,\n * 'cursor-abc',\n * 'cursor-xyz'\n * )\n */\nexport const toCursorListEntity = <T, E extends Entity<T>>(\n req: Request,\n data: E[],\n pageSize: number,\n nextCursor?: string | undefined,\n prevCursor?: string | undefined\n): CursorListEntity<T> => {\n const _meta: CursorMeta = {\n pageSize,\n ...(nextCursor && { nextCursor }),\n ...(prevCursor && { prevCursor }),\n }\n\n const links = {\n self: { method: 'GET', href: resolveUrl(req) } as Link,\n first: { method: 'GET', href: resolveUrl(req, buildFirstUrl(req)) } as Link,\n ...(nextCursor && {\n next: {\n method: 'GET',\n href: resolveUrl(\n req,\n buildCursorUrl(req, nextCursor, 'next_cursor', pageSize)\n ),\n } as Link,\n }),\n ...(prevCursor && {\n prev: {\n method: 'GET',\n href: resolveUrl(\n req,\n buildCursorUrl(req, prevCursor, 'prev_cursor', pageSize)\n ),\n } as Link,\n }),\n }\n\n return { data, _meta, links }\n}\n","import type { Request } from 'express'\nimport type { Entity, Link } from './types'\nimport { resolveUrl } from './url'\n\n/**\n * Normalises a mixed link record by converting string shorthands to full\n * {@link Link} objects with a default GET method.\n *\n * @param links - A record of links, either as strings or full {@link Link} objects.\n * @returns A record of full {@link Link} objects.\n */\nconst normaliseLinks = (\n links: Record<string, string | Link>\n): Record<string, Link> => {\n const result: Record<string, Link> = {}\n for (const [name, link] of Object.entries(links)) {\n result[name] =\n typeof link === 'string' ? { method: 'GET', href: link } : link\n }\n return result\n}\n\n/**\n * Wraps data in a hypermedia entity envelope with a self link and any\n * additional resolved links.\n *\n * The self link is always derived from the request and cannot be overridden.\n * All link hrefs are resolved against the public-facing URL constructed from\n * forwarded headers.\n *\n * String links are treated as GET requests:\n * ```\n * { parent: '/orders' }\n * // is equivalent to\n * { parent: { method: 'GET', href: '/orders' } }\n * ```\n *\n * @param req - The Express request, used to construct public-facing URLs.\n * @param data - The data to wrap.\n * @param links - Optional record of links, either as strings or full {@link Link} objects.\n * @returns An {@link Entity} with resolved links and a self link.\n *\n * @example\n * const entity = toEntity(req, order, {\n * parent: '/orders',\n * cancel: { method: 'DELETE', href: './cancel', title: 'Cancel order' },\n * })\n */\nexport const toEntity = <T>(\n req: Request,\n data: T,\n links: Record<string, string | Link> = {}\n): Entity<T> => {\n const resolvedLinks: Record<string, Link> = {}\n for (const [name, { method, href }] of Object.entries(\n normaliseLinks(links)\n )) {\n resolvedLinks[name] = { method, href: resolveUrl(req, href) }\n }\n\n return {\n data,\n links: {\n self: { method: 'GET', href: resolveUrl(req) },\n ...resolvedLinks,\n },\n }\n}\n","import type { Request } from 'express'\nimport type { Entity, Link, PageListEntity } from './types'\nimport { resolveUrl } from './url'\n\n/**\n * Wraps a pre-mapped list of entities in a page-based pagination envelope.\n *\n * prev is absent on the first page, next is absent on the last page.\n * All existing query params such as filters are preserved and merged with\n * pagination params.\n *\n * @param req - The Express request, used to construct public-facing URLs.\n * @param data - Pre-mapped entities, each wrapped with {@link toEntity}.\n * @param page - Current page number (1-based).\n * @param pageSize - Number of items per page.\n * @param total - Total number of items across all pages.\n * @returns A {@link PageListEntity} with self, first, last, and optional prev/next links.\n *\n * @example\n * const entity = toPageListEntity(\n * req,\n * orders.map((o) => toEntity(req, o, { self: `./orders/${o.id}` })),\n * page,\n * pageSize,\n * total\n * )\n */\nexport const toPageListEntity = <T, E extends Entity<T>>(\n req: Request,\n data: E[],\n page: number,\n pageSize: number,\n total: number\n): PageListEntity<T> => {\n const lastPage = Math.ceil(total / pageSize)\n\n const buildLink = (p: number): Link => ({\n method: 'GET',\n href: resolveUrl(req, buildPageUrl(req, p, pageSize)),\n })\n\n return {\n data,\n _meta: { page, pageSize, total },\n links: {\n self: { method: 'GET', href: resolveUrl(req) },\n first: buildLink(1),\n last: buildLink(lastPage),\n ...(page > 1 && { prev: buildLink(page - 1) }),\n ...(page < lastPage && { next: buildLink(page + 1) }),\n },\n }\n}\n\n/**\n * Builds a page navigation URL by merging the page and page size into the\n * current request's query params, preserving any existing params such as filters.\n *\n * @param req - The Express request.\n * @param page - The page number to navigate to.\n * @param pageSize - Number of items per page.\n * @returns A path-relative URL with page and page_size query params merged in.\n */\nconst buildPageUrl = (req: Request, page: number, pageSize: number): string => {\n const [path, queryString] = req.originalUrl.split('?')\n const params = new URLSearchParams(queryString)\n params.set('page', String(page))\n params.set('page_size', String(pageSize))\n return `${path}?${params.toString()}`\n}\n"],"mappings":"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"} |
+1
-1
| { | ||
| "name": "@sebspark/hyper-media", | ||
| "version": "0.1.1", | ||
| "version": "0.1.2", | ||
| "license": "Apache-2.0", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+4
-5
@@ -50,5 +50,4 @@ # @sebspark/hyper-media | ||
| |---|---| | ||
| | `X-Forwarded-Host` | Public-facing hostname set by Kong | | ||
| | `X-Forwarded-Proto` | Protocol set by Kong (not used in href, implicit `//`) | | ||
| | `X-Forwarded-Prefix` | Path prefix accumulated across ALB hops | | ||
| | `X-Forwarded-Host` | Public-facing hostname set by Gateway | | ||
| | `X-Forwarded-Prefix` | Path prefix accumulated across Load Balancer hops | | ||
@@ -114,3 +113,3 @@ All hrefs use implicit protocol (`//host/path`) so the client inherits the protocol from the page context. | ||
| `self` is always derived from the request and cannot be overridden by the caller. | ||
| `self` is derived from the request unless overridden overridden by the caller. | ||
@@ -194,3 +193,3 @@ Links accept either a full `Link` object or a string shorthand which defaults to `GET`: | ||
| ``` | ||
| Kong | ||
| Gateway | ||
| → sets X-Forwarded-Host: api.example.com | ||
@@ -197,0 +196,0 @@ → sets X-Forwarded-Proto: https |
50048
-0.14%208
-0.48%