From c1791606f69411f1ec66f28dbef4f56c0e06d522 Mon Sep 17 00:00:00 2001 From: chanhwi Date: Mon, 29 Dec 2025 20:35:39 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=E2=9C=A8=20add=20NURL.match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nurl.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/nurl.ts b/src/nurl.ts index 18aa041..31420bd 100644 --- a/src/nurl.ts +++ b/src/nurl.ts @@ -472,4 +472,33 @@ export default class NURL implements URL { .map((segment) => decode(segment.replace(this.punycodePrefix, ''))) .join('.') } + + static match(url: string, pattern: string) { + if (!NURL.canParse(url) || !NURL.canParse(pattern)) { + return null + } + + const urlSegments = url.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] + const patternSegments = pattern.split('/').filter(Boolean) + + if (urlSegments.length !== patternSegments.length) { + return null + } + + const params: Record = {} + + for (let i = 0; i < patternSegments.length; i++) { + const patternSegment = patternSegments[i] + const urlSegment = urlSegments[i] + + if (isDynamicPath(patternSegment)) { + const pathKey = extractPathKey(patternSegment) + params[pathKey] = urlSegment + } else if (patternSegment !== urlSegment) { + return null + } + } + + return params + } } From 78e0e6885d5cd4fb065c9065380407d66878448d Mon Sep 17 00:00:00 2001 From: chanhwi Date: Mon, 29 Dec 2025 20:36:03 +0900 Subject: [PATCH 02/13] =?UTF-8?q?=E2=9C=A8=20add=20NURL.mask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nurl.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/nurl.ts b/src/nurl.ts index 31420bd..25198d3 100644 --- a/src/nurl.ts +++ b/src/nurl.ts @@ -31,6 +31,14 @@ interface URLOptions basePath?: string } +interface MaskOptions { + patterns: string[] + sensitiveParams: string[] + maskChar?: string + maskLength?: number + preserveLength?: boolean +} + export default class NURL implements URL { private _href: string = '' private _protocol: string = '' @@ -501,4 +509,29 @@ export default class NURL implements URL { return params } + + static mask( + url: string, + {patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions, + ) { + for (const pattern of patterns) { + const urlObj = NURL.create(url) + const matchedParams = NURL.match(urlObj.pathname, pattern) + if (!matchedParams) { + continue + } + sensitiveParams.forEach((sensitiveParam) => { + if (sensitiveParam in matchedParams) { + const originalValue = matchedParams[sensitiveParam] + const lengthToMask = preserveLength ? originalValue.length : maskLength + matchedParams[sensitiveParam] = maskChar.repeat(lengthToMask) + } + }) + + urlObj.pathname = refinePathnameWithQuery(pattern, matchedParams) + return urlObj.toString() + } + + return url + } } From 4a4ab47a3133d785f40fb0875084c170d745b2c2 Mon Sep 17 00:00:00 2001 From: chanhwi Date: Mon, 29 Dec 2025 20:37:10 +0900 Subject: [PATCH 03/13] =?UTF-8?q?=E2=9C=85=20add=20tests=20for=20NURL.matc?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 0d17303..6b21965 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -484,6 +484,44 @@ describe('NURL', () => { expect(NURL.canParse(input)).toBe(expected) }) }) + + describe('NURL.match', () => { + test.each([ + ['/v1/user/12345/info', '/v1/user/:userId/info', {userId: '12345'}], + ['/v1/user/12345/info', '/v1/user/[userId]/info', {userId: '12345'}], + ['/v1/user/12345/info', '/v1/user/:123test/info', {'123test': '12345'}], + ['/v1/user/12345/info', '/v1/user/[한글]/info', {한글: '12345'}], + [ + '/v1/friends/SENDMONEY/block/111/222', + '/v1/friends/:serviceCode/block/[nidNo]/:friendNidNo', + {serviceCode: 'SENDMONEY', nidNo: '111', friendNidNo: '222'}, + ], + [ + '/articles/2023/08/15/my-article', + '/articles/:year/:month/:day/[title]', + {year: '2023', month: '08', day: '15', title: 'my-article'}, + ], + [ + 'https://files/documents/report.pdf', + 'https://files/:folder/[filename]', + {folder: 'documents', filename: 'report.pdf'}, + ], + ['/example.com/example/path/1234?q1=123&q2=aaa#hash', '/example.com/example/path/:id', {id: '1234'}], + ])('should match %s with pattern %s to extract %o', (url, pattern, expected) => { + const result = NURL.match(url, pattern) + expect(result).toEqual(expected) + }) + + test('should return null when there is no match', () => { + const result = NURL.match('/v1/user/12345/info', '/v1/admin/:adminId/dashboard') + expect(result).toBeNull() + }) + + test('should return empty object when there are no dynamic segments', () => { + const result = NURL.match('/no/dynamic/segments', '/no/dynamic/segments') + expect(result).toEqual({}) + }) + }) }) describe('Extended functionality', () => { From f7e7ce14b7ddb050c9a097d1cb1afaf476c447c8 Mon Sep 17 00:00:00 2001 From: chanhwi Date: Mon, 29 Dec 2025 21:48:20 +0900 Subject: [PATCH 04/13] =?UTF-8?q?=E2=9C=85=20add=20tests=20for=20NURL.mask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.test.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++- src/nurl.ts | 6 ++- src/utils.ts | 15 ++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 6b21965..9df45e8 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,6 @@ import {describe, test, expect} from 'vitest' -import NURL from './nurl' +import NURL, {MaskOptions} from './nurl' const compareNurlWithUrl = ({url, nurl}: {url: URL; nurl: NURL}) => { expect(nurl.toString()).toBe(url.toString()) @@ -522,6 +522,100 @@ describe('NURL', () => { expect(result).toEqual({}) }) }) + + describe('NURL.mask', () => { + const friendApiPathPatterns = [ + '/v1/friends/:serviceCode/:friendNo', + '/v1/friends/:friendNidNo', + '/v1/friends/:serviceCode/favorite', + '/v1/friends/close-friends', + ] + const friendApiSensitiveParams = ['friendNo', 'friendNidNo'] + + test.each([ + [ + '/v1/user/12345/info', + {patterns: ['/v1/user/:userId/info'], sensitiveParams: ['userId']}, + '/v1/user/****/info', + ], + [ + '/v1/user/12345/info', + {patterns: ['/v1/user/[userId]/info'], sensitiveParams: ['userId'], maskChar: 'X', maskLength: 6}, + '/v1/user/XXXXXX/info', + ], + [ + '/v1/user/12345/info', + {patterns: ['/v1/user/:userId/info'], sensitiveParams: ['userId'], preserveLength: true}, + '/v1/user/*****/info', + ], + [ + '/v1/friends/SENDMONEY/block/12345/67890', + { + patterns: ['/v1/friends/:serviceCode/block/:nidNo/:friendNidNo'], + sensitiveParams: ['nidNo', 'friendNidNo'], + preserveLength: true, + }, + '/v1/friends/SENDMONEY/block/*****/*****', + ], + [ + 'https://example.com/v1/friends/SENDMONEY/block/12345/67890?q=test#section', + { + patterns: ['/v1/friends/:serviceCode/block/:nidNo/:friendNidNo'], + sensitiveParams: ['nidNo', 'friendNidNo'], + preserveLength: true, + }, + 'https://example.com/v1/friends/SENDMONEY/block/*****/*****?q=test#section', + ], + [ + '/v1/friends/12345678', + { + patterns: friendApiPathPatterns, + sensitiveParams: friendApiSensitiveParams, + preserveLength: true, + }, + '/v1/friends/********', + ], + [ + '/v1/friends/close-friends', + { + patterns: friendApiPathPatterns, + sensitiveParams: friendApiSensitiveParams, + preserveLength: true, + }, + '/v1/friends/close-friends', + ], + [ + '/v1/friends/SENDMONEY/favorite', + { + patterns: friendApiPathPatterns, + sensitiveParams: friendApiSensitiveParams, + preserveLength: true, + }, + '/v1/friends/SENDMONEY/favorite', + ], + [ + '/v1/friends/SENDMONEY/12345', + { + patterns: friendApiPathPatterns, + sensitiveParams: friendApiSensitiveParams, + preserveLength: true, + }, + '/v1/friends/SENDMONEY/*****', + ], + [ + '/user/admin/profile', + { + patterns: ['/user/:id/profile', '/user/admin/:tab'], + sensitiveParams: ['id'], + preserveLength: true, + }, + '/user/admin/profile', + ], + ])('should mask %s with options %o to %s', (url, options: MaskOptions, expected) => { + const result = NURL.mask(url, options) + expect(result).toBe(expected) + }) + }) }) describe('Extended functionality', () => { diff --git a/src/nurl.ts b/src/nurl.ts index 25198d3..8f1ced6 100644 --- a/src/nurl.ts +++ b/src/nurl.ts @@ -8,6 +8,7 @@ import { refineQueryWithPathname, convertQueryToArray, Query, + getPathStructure, } from './utils' interface URLOptions @@ -31,7 +32,7 @@ interface URLOptions basePath?: string } -interface MaskOptions { +export interface MaskOptions { patterns: string[] sensitiveParams: string[] maskChar?: string @@ -514,7 +515,8 @@ export default class NURL implements URL { url: string, {patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions, ) { - for (const pattern of patterns) { + const sortedPatterns = patterns.sort((a, b) => (getPathStructure(b) > getPathStructure(a) ? 1 : -1)) + for (const pattern of sortedPatterns) { const urlObj = NURL.create(url) const matchedParams = NURL.match(urlObj.pathname, pattern) if (!matchedParams) { diff --git a/src/utils.ts b/src/utils.ts index 668f784..64bef4b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -79,3 +79,18 @@ export function convertQueryToArray(query: Query): string[][] { return [] }) } + +/** + * Get path structure, dynamic paths are represented as '1' and static paths as '2'. + * + * @param {string} pathname + * @returns {number} path structure + * + * @example /user/:id/profile -> 212 + * @example /user/admin/:tab -> 221 + */ +export function getPathStructure(pathname: string): string { + const segments = pathname.split('/').filter(Boolean) + + return segments.map((segment) => (isDynamicPath(segment) ? '1' : '2')).join('') +} From 009c78645f01508caf5cdaf94fa1dbb1b2aacbdb Mon Sep 17 00:00:00 2001 From: chanhwi Date: Mon, 29 Dec 2025 23:47:05 +0900 Subject: [PATCH 05/13] =?UTF-8?q?=F0=9F=93=9D=20add=20static=20methods=20s?= =?UTF-8?q?ection=20in=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2dddee0..a19e2d9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ NURL is a powerful URL manipulation library that extends the standard URL class. ### Basic Usage ```javascript -import { NURL } from 'nurl' +import {NURL} from 'nurl' // Create URL from string const url1 = new NURL('https://example.com/users/123?name=John') @@ -36,9 +36,9 @@ const url2 = new NURL(standardUrl) // Create URL from custom options object const url3 = new NURL({ - baseUrl: 'https://example.com', - pathname: '/users/:id', - query: { id: '123', name: 'John' } + baseUrl: 'https://example.com', + pathname: '/users/:id', + query: {id: '123', name: 'John'}, }) // Create empty URL @@ -49,9 +49,9 @@ const url5 = NURL.create('https://example.com') // The factory function also works with options object const url6 = NURL.create({ - baseUrl: 'https://example.com', - pathname: '/users/:id', - query: { id: '123', name: 'John' } + baseUrl: 'https://example.com', + pathname: '/users/:id', + query: {id: '123', name: 'John'}, }) ``` @@ -61,13 +61,13 @@ NURL processes dynamic segments in the pathname and replaces them with values fr ```javascript const url = new NURL({ - baseUrl: 'https://api.example.com', - pathname: '/users/:a/posts/[b]/[c]', - query: { - a: '123', - b: '456', - format: 'json' - } + baseUrl: 'https://api.example.com', + pathname: '/users/:a/posts/[b]/[c]', + query: { + a: '123', + b: '456', + format: 'json', + }, }) console.log(url.href) @@ -84,6 +84,59 @@ console.log(url.hostname) // xn--bj0bj06e.xn--hq1bm8jm9l console.log(url.decodedHostname) // 한글.도메인 (in human-readable format) ``` +### URL Pattern Matching + +NURL supports `NURL.match(url, pattern)` static method to match a URL path against a pattern with dynamic segments: + +```tsx +NURL.match('/v1/user/12345/info', '/v1/user/:userId/info') +// → { userId: '12345' } + +NURL.match('/v1/friends/SENDMONEY/block/111/222', '/v1/friends/:serviceCode/block/:nidNo/:friendNidNo') +// → { serviceCode: 'SENDMONEY', nidNo: '111', friendNidNo: '222' } + +NURL.match('/v1/user/12345', '/v1/admin/:id') +// → null (no match) +``` + +### Masking Path Parameters + +NURL provides `NURL.mask(url, options)` static method to mask sensitive path parameters in a URL for logging purposes: + +```tsx +// Default masking (**** with length 4) +NURL.mask('/v1/user/12345/info', { + patterns: ['/v1/user/:userId/info'], + sensitiveParams: ['userId'], +}) +// → '/v1/user/****/info' + +// Custom mask character and length +NURL.mask('/v1/user/12345/info', { + patterns: ['/v1/user/[userId]/info'], + sensitiveParams: ['userId'], + maskChar: 'X', + maskLength: 6, +}) +// → '/v1/user/XXXXXX/info' + +// Preserve original value length +NURL.mask('/v1/user/12345/info', { + patterns: ['/v1/user/:userId/info'], + sensitiveParams: ['userId'], + preserveLength: true, +}) +// → '/v1/user/*****/info' (5 chars, same as '12345') + +// Multiple sensitive params +NURL.mask('/v1/friends/SENDMONEY/block/12345/67890', { + patterns: ['/v1/friends/:serviceCode/block/[nidNo]/:friendNidNo'], + sensitiveParams: ['nidNo', 'friendNidNo'], + preserveLength: true, +}) +// → '/v1/friends/SENDMONEY/block/*****/*****' (5 and 5 chars) +``` + ## API ### `constructor(input?: string | URL | URLOptions)` @@ -113,6 +166,23 @@ NURL inherits all properties from the standard URL class: - `toString()`: Returns the URL as a string - `toJSON()`: Returns the URL as a JSON representation +### Static Methods + +- `NURL.create(input?: string | URL | URLOptions): NURL` + - Factory function to create a NURL instance without the `new` keyword. +- `NURL.canParse(url: string): boolean` + - Checks if the given string can be parsed as a valid URL. +- `NURL.match(url: string, pattern: string): Record | null` + - Matches a URL path against a pattern with dynamic segments and returns an object with extracted parameters or `null` if no match. +- `NURL.mask(url: string, options: MaskOptions): string` + - Masks sensitive path parameters in a URL based on the provided options. + - `MaskOptions`: + - `patterns: string[]`: Array of URL patterns with dynamic segments. + - `sensitiveParams: string[]`: Array of path parameters to be masked. + - `maskChar?: string`: Character used for masking (default: `'*'`). + - `maskLength?: number`: Length of the mask (default: `4`). + - `preserveLength?: boolean`: If true, mask length matches original value length (overrides `maskLength`). + ## Important Notes 1. NURL's setter methods behave differently from the standard URL. They are designed to consider dynamic segment and query parameter replacement functionality. From b5e6fb5b01b9e61d0ba0c1eddb256d881a9e8434 Mon Sep 17 00:00:00 2001 From: chanhwi Date: Tue, 30 Dec 2025 10:59:28 +0900 Subject: [PATCH 06/13] =?UTF-8?q?=F0=9F=90=9B=20sort=20copy=20of=20pattern?= =?UTF-8?q?s=20to=20preserve=20original=20array?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nurl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nurl.ts b/src/nurl.ts index 8f1ced6..28e3049 100644 --- a/src/nurl.ts +++ b/src/nurl.ts @@ -515,7 +515,7 @@ export default class NURL implements URL { url: string, {patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions, ) { - const sortedPatterns = patterns.sort((a, b) => (getPathStructure(b) > getPathStructure(a) ? 1 : -1)) + const sortedPatterns = [...patterns].sort((a, b) => (getPathStructure(b) > getPathStructure(a) ? 1 : -1)) for (const pattern of sortedPatterns) { const urlObj = NURL.create(url) const matchedParams = NURL.match(urlObj.pathname, pattern) From 58ff828357401170ee903797721cc9c48a31b10a Mon Sep 17 00:00:00 2001 From: chanhwi Date: Tue, 30 Dec 2025 11:05:41 +0900 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=90=9B=20handle=20case=20that=20hav?= =?UTF-8?q?e=20query=20and=20hash=20in=20pattern=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.test.ts | 1 + src/nurl.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.test.ts b/src/index.test.ts index 9df45e8..636ea59 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -507,6 +507,7 @@ describe('NURL', () => { {folder: 'documents', filename: 'report.pdf'}, ], ['/example.com/example/path/1234?q1=123&q2=aaa#hash', '/example.com/example/path/:id', {id: '1234'}], + ['/example.com/example/path/1234', '/example.com/example/path/:id?q1=123&q2=aaa#hash', {id: '1234'}], ])('should match %s with pattern %s to extract %o', (url, pattern, expected) => { const result = NURL.match(url, pattern) expect(result).toEqual(expected) diff --git a/src/nurl.ts b/src/nurl.ts index 28e3049..225e7b8 100644 --- a/src/nurl.ts +++ b/src/nurl.ts @@ -488,7 +488,7 @@ export default class NURL implements URL { } const urlSegments = url.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] - const patternSegments = pattern.split('/').filter(Boolean) + const patternSegments = pattern.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] if (urlSegments.length !== patternSegments.length) { return null From dc9318ba7e6082d5a4048b6889888f9637c8ef45 Mon Sep 17 00:00:00 2001 From: chanhwi Date: Tue, 30 Dec 2025 11:14:31 +0900 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=90=9B=20rename=20getPathStructure?= =?UTF-8?q?=20to=20getPathPriority=20for=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nurl.ts | 4 ++-- src/utils.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/nurl.ts b/src/nurl.ts index 225e7b8..4d9ff5d 100644 --- a/src/nurl.ts +++ b/src/nurl.ts @@ -8,7 +8,7 @@ import { refineQueryWithPathname, convertQueryToArray, Query, - getPathStructure, + getPathPriority, } from './utils' interface URLOptions @@ -515,7 +515,7 @@ export default class NURL implements URL { url: string, {patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions, ) { - const sortedPatterns = [...patterns].sort((a, b) => (getPathStructure(b) > getPathStructure(a) ? 1 : -1)) + const sortedPatterns = [...patterns].sort((a, b) => (getPathPriority(b) > getPathPriority(a) ? 1 : -1)) for (const pattern of sortedPatterns) { const urlObj = NURL.create(url) const matchedParams = NURL.match(urlObj.pathname, pattern) diff --git a/src/utils.ts b/src/utils.ts index 64bef4b..82128aa 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -81,15 +81,16 @@ export function convertQueryToArray(query: Query): string[][] { } /** - * Get path structure, dynamic paths are represented as '1' and static paths as '2'. + * Get path priority representation, dynamic paths are represented as '1' and static paths as '2'. + * Used for matching the most specific route. * * @param {string} pathname - * @returns {number} path structure + * @returns {string} path priority representation * * @example /user/:id/profile -> 212 * @example /user/admin/:tab -> 221 */ -export function getPathStructure(pathname: string): string { +export function getPathPriority(pathname: string): string { const segments = pathname.split('/').filter(Boolean) return segments.map((segment) => (isDynamicPath(segment) ? '1' : '2')).join('') From 92343a8f06aea570d2046260a4a6a9f73ee79f18 Mon Sep 17 00:00:00 2001 From: yceffort Date: Tue, 30 Dec 2025 11:51:57 +0900 Subject: [PATCH 09/13] chore: add changeset for masking feature --- .changeset/smart-pens-march.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/smart-pens-march.md diff --git a/.changeset/smart-pens-march.md b/.changeset/smart-pens-march.md new file mode 100644 index 0000000..e814d89 --- /dev/null +++ b/.changeset/smart-pens-march.md @@ -0,0 +1,8 @@ +--- +"@naverpay/nurl": minor +--- + +feat: add `NURL.match()` and `NURL.mask()` static methods + +- `NURL.match(url, pattern)`: Match URL path against a pattern with dynamic segments and extract parameters +- `NURL.mask(url, options)`: Mask sensitive path parameters in a URL for logging purposes From ef63d7f122f8a1a8508f0fd309712d0d84e7967f Mon Sep 17 00:00:00 2001 From: chanhwi Date: Tue, 30 Dec 2025 12:36:46 +0900 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=94=A7=20export=20match,=20mask=20u?= =?UTF-8?q?tils=20as=20separate=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 10 +++++ src/nurl.ts | 65 +++-------------------------- src/utils/external.ts | 65 +++++++++++++++++++++++++++++ src/utils/index.ts | 1 + src/{utils.ts => utils/internal.ts} | 0 vite.config.mts | 1 + 6 files changed, 82 insertions(+), 60 deletions(-) create mode 100644 src/utils/external.ts create mode 100644 src/utils/index.ts rename src/{utils.ts => utils/internal.ts} (100%) diff --git a/package.json b/package.json index fa0781b..9121931 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,16 @@ "default": "./dist/cjs/index.js" } }, + "./utils": { + "import": { + "types": "./dist/esm/utils.d.mts", + "default": "./dist/esm/utils/index.mjs" + }, + "require": { + "types": "./dist/cjs/utils.d.ts", + "default": "./dist/cjs/utils/index.js" + } + }, "./package.json": "./package.json" }, "files": [ diff --git a/src/nurl.ts b/src/nurl.ts index 4d9ff5d..de40308 100644 --- a/src/nurl.ts +++ b/src/nurl.ts @@ -1,4 +1,5 @@ import {decode, encode} from './punycode' +import {mask, MaskOptions, match as matchUrlPattern} from './utils' import { extractPathKey, getDynamicPaths, @@ -8,8 +9,7 @@ import { refineQueryWithPathname, convertQueryToArray, Query, - getPathPriority, -} from './utils' +} from './utils/internal' interface URLOptions extends Partial< @@ -32,14 +32,6 @@ interface URLOptions basePath?: string } -export interface MaskOptions { - patterns: string[] - sensitiveParams: string[] - maskChar?: string - maskLength?: number - preserveLength?: boolean -} - export default class NURL implements URL { private _href: string = '' private _protocol: string = '' @@ -483,57 +475,10 @@ export default class NURL implements URL { } static match(url: string, pattern: string) { - if (!NURL.canParse(url) || !NURL.canParse(pattern)) { - return null - } - - const urlSegments = url.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] - const patternSegments = pattern.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] - - if (urlSegments.length !== patternSegments.length) { - return null - } - - const params: Record = {} - - for (let i = 0; i < patternSegments.length; i++) { - const patternSegment = patternSegments[i] - const urlSegment = urlSegments[i] - - if (isDynamicPath(patternSegment)) { - const pathKey = extractPathKey(patternSegment) - params[pathKey] = urlSegment - } else if (patternSegment !== urlSegment) { - return null - } - } - - return params + return matchUrlPattern(url, pattern) } - static mask( - url: string, - {patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions, - ) { - const sortedPatterns = [...patterns].sort((a, b) => (getPathPriority(b) > getPathPriority(a) ? 1 : -1)) - for (const pattern of sortedPatterns) { - const urlObj = NURL.create(url) - const matchedParams = NURL.match(urlObj.pathname, pattern) - if (!matchedParams) { - continue - } - sensitiveParams.forEach((sensitiveParam) => { - if (sensitiveParam in matchedParams) { - const originalValue = matchedParams[sensitiveParam] - const lengthToMask = preserveLength ? originalValue.length : maskLength - matchedParams[sensitiveParam] = maskChar.repeat(lengthToMask) - } - }) - - urlObj.pathname = refinePathnameWithQuery(pattern, matchedParams) - return urlObj.toString() - } - - return url + static mask(url: string, options: MaskOptions) { + return mask(url, options) } } diff --git a/src/utils/external.ts b/src/utils/external.ts new file mode 100644 index 0000000..1e0240f --- /dev/null +++ b/src/utils/external.ts @@ -0,0 +1,65 @@ +import NURL from '../nurl' +import {extractPathKey, getPathPriority, isDynamicPath, refinePathnameWithQuery} from './internal' + +export const match = (url: string, pattern: string): Record | null => { + if (!NURL.canParse(url) || !NURL.canParse(pattern)) { + return null + } + + const urlSegments = url.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] + const patternSegments = pattern.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] + + if (urlSegments.length !== patternSegments.length) { + return null + } + + const params: Record = {} + + for (let i = 0; i < patternSegments.length; i++) { + const patternSegment = patternSegments[i] + const urlSegment = urlSegments[i] + + if (isDynamicPath(patternSegment)) { + const pathKey = extractPathKey(patternSegment) + params[pathKey] = urlSegment + } else if (patternSegment !== urlSegment) { + return null + } + } + + return params +} + +export interface MaskOptions { + patterns: string[] + sensitiveParams: string[] + maskChar?: string + maskLength?: number + preserveLength?: boolean +} + +export const mask = ( + url: string, + {patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions, +) => { + const sortedPatterns = [...patterns].sort((a, b) => (getPathPriority(b) > getPathPriority(a) ? 1 : -1)) + for (const pattern of sortedPatterns) { + const urlObj = NURL.create(url) + const matchedParams = match(urlObj.pathname, pattern) + if (!matchedParams) { + continue + } + sensitiveParams.forEach((sensitiveParam) => { + if (sensitiveParam in matchedParams) { + const originalValue = matchedParams[sensitiveParam] + const lengthToMask = preserveLength ? originalValue.length : maskLength + matchedParams[sensitiveParam] = maskChar.repeat(lengthToMask) + } + }) + + urlObj.pathname = refinePathnameWithQuery(pattern, matchedParams) + return urlObj.toString() + } + + return url +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..eea7f38 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from './external' diff --git a/src/utils.ts b/src/utils/internal.ts similarity index 100% rename from src/utils.ts rename to src/utils/internal.ts diff --git a/vite.config.mts b/vite.config.mts index f66a3da..2ebc1d1 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -9,6 +9,7 @@ export default createViteConfig({ cwd: __dirname, entry: { index: './src/index.ts', + utils: './src/utils/index.ts', }, outputs: [ {format: 'es', dist: 'dist/esm'}, From e9272780aac978ab5ef6378cf63abc66b5705330 Mon Sep 17 00:00:00 2001 From: chanhwi Date: Tue, 30 Dec 2025 16:38:08 +0900 Subject: [PATCH 11/13] =?UTF-8?q?=F0=9F=94=A7=20merge=20utilities=20and=20?= =?UTF-8?q?export=20all=20functions=20and=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- src/nurl.ts | 6 ++- src/{utils/internal.ts => utils.ts} | 65 +++++++++++++++++++++++++++++ src/utils/external.ts | 65 ----------------------------- src/utils/index.ts | 1 - vite.config.mts | 2 +- 6 files changed, 72 insertions(+), 71 deletions(-) rename src/{utils/internal.ts => utils.ts} (59%) delete mode 100644 src/utils/external.ts delete mode 100644 src/utils/index.ts diff --git a/package.json b/package.json index 9121931..40de6ae 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,11 @@ "./utils": { "import": { "types": "./dist/esm/utils.d.mts", - "default": "./dist/esm/utils/index.mjs" + "default": "./dist/esm/utils.mjs" }, "require": { "types": "./dist/cjs/utils.d.ts", - "default": "./dist/cjs/utils/index.js" + "default": "./dist/cjs/utils.js" } }, "./package.json": "./package.json" diff --git a/src/nurl.ts b/src/nurl.ts index de40308..a21ae8b 100644 --- a/src/nurl.ts +++ b/src/nurl.ts @@ -1,5 +1,4 @@ import {decode, encode} from './punycode' -import {mask, MaskOptions, match as matchUrlPattern} from './utils' import { extractPathKey, getDynamicPaths, @@ -9,7 +8,10 @@ import { refineQueryWithPathname, convertQueryToArray, Query, -} from './utils/internal' + match as matchUrlPattern, + mask, + MaskOptions, +} from './utils' interface URLOptions extends Partial< diff --git a/src/utils/internal.ts b/src/utils.ts similarity index 59% rename from src/utils/internal.ts rename to src/utils.ts index 82128aa..aaf94a1 100644 --- a/src/utils/internal.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import NURL from './nurl' + const DYNAMIC_PATH_COLON_REGEXP = /^:/ const DYNAMIC_PATH_BRACKETS_REGEXP = /^\[.*\]$/ @@ -95,3 +97,66 @@ export function getPathPriority(pathname: string): string { return segments.map((segment) => (isDynamicPath(segment) ? '1' : '2')).join('') } + +export const match = (url: string, pattern: string): Record | null => { + if (!NURL.canParse(url) || !NURL.canParse(pattern)) { + return null + } + + const urlSegments = url.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] + const patternSegments = pattern.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] + + if (urlSegments.length !== patternSegments.length) { + return null + } + + const params: Record = {} + + for (let i = 0; i < patternSegments.length; i++) { + const patternSegment = patternSegments[i] + const urlSegment = urlSegments[i] + + if (isDynamicPath(patternSegment)) { + const pathKey = extractPathKey(patternSegment) + params[pathKey] = urlSegment + } else if (patternSegment !== urlSegment) { + return null + } + } + + return params +} + +export interface MaskOptions { + patterns: string[] + sensitiveParams: string[] + maskChar?: string + maskLength?: number + preserveLength?: boolean +} + +export const mask = ( + url: string, + {patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions, +) => { + const sortedPatterns = [...patterns].sort((a, b) => (getPathPriority(b) > getPathPriority(a) ? 1 : -1)) + for (const pattern of sortedPatterns) { + const urlObj = NURL.create(url) + const matchedParams = match(urlObj.pathname, pattern) + if (!matchedParams) { + continue + } + sensitiveParams.forEach((sensitiveParam) => { + if (sensitiveParam in matchedParams) { + const originalValue = matchedParams[sensitiveParam] + const lengthToMask = preserveLength ? originalValue.length : maskLength + matchedParams[sensitiveParam] = maskChar.repeat(lengthToMask) + } + }) + + urlObj.pathname = refinePathnameWithQuery(pattern, matchedParams) + return urlObj.toString() + } + + return url +} diff --git a/src/utils/external.ts b/src/utils/external.ts deleted file mode 100644 index 1e0240f..0000000 --- a/src/utils/external.ts +++ /dev/null @@ -1,65 +0,0 @@ -import NURL from '../nurl' -import {extractPathKey, getPathPriority, isDynamicPath, refinePathnameWithQuery} from './internal' - -export const match = (url: string, pattern: string): Record | null => { - if (!NURL.canParse(url) || !NURL.canParse(pattern)) { - return null - } - - const urlSegments = url.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] - const patternSegments = pattern.split(/[?#]/)[0]?.split('/').filter(Boolean) || [] - - if (urlSegments.length !== patternSegments.length) { - return null - } - - const params: Record = {} - - for (let i = 0; i < patternSegments.length; i++) { - const patternSegment = patternSegments[i] - const urlSegment = urlSegments[i] - - if (isDynamicPath(patternSegment)) { - const pathKey = extractPathKey(patternSegment) - params[pathKey] = urlSegment - } else if (patternSegment !== urlSegment) { - return null - } - } - - return params -} - -export interface MaskOptions { - patterns: string[] - sensitiveParams: string[] - maskChar?: string - maskLength?: number - preserveLength?: boolean -} - -export const mask = ( - url: string, - {patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions, -) => { - const sortedPatterns = [...patterns].sort((a, b) => (getPathPriority(b) > getPathPriority(a) ? 1 : -1)) - for (const pattern of sortedPatterns) { - const urlObj = NURL.create(url) - const matchedParams = match(urlObj.pathname, pattern) - if (!matchedParams) { - continue - } - sensitiveParams.forEach((sensitiveParam) => { - if (sensitiveParam in matchedParams) { - const originalValue = matchedParams[sensitiveParam] - const lengthToMask = preserveLength ? originalValue.length : maskLength - matchedParams[sensitiveParam] = maskChar.repeat(lengthToMask) - } - }) - - urlObj.pathname = refinePathnameWithQuery(pattern, matchedParams) - return urlObj.toString() - } - - return url -} diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index eea7f38..0000000 --- a/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './external' diff --git a/vite.config.mts b/vite.config.mts index 2ebc1d1..8693cb8 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -9,7 +9,7 @@ export default createViteConfig({ cwd: __dirname, entry: { index: './src/index.ts', - utils: './src/utils/index.ts', + utils: './src/utils.ts', }, outputs: [ {format: 'es', dist: 'dist/esm'}, From c889cbe5d4425008feb07163935a4359bf1568ff Mon Sep 17 00:00:00 2001 From: chanhwi Date: Wed, 31 Dec 2025 13:51:55 +0900 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=9A=9A=20change=20utils=20directory?= =?UTF-8?q?=20structure=20and=20entry=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 ++-- src/utils/index.ts | 1 + src/{utils.ts => utils/url.ts} | 6 +++--- vite.config.mts | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 src/utils/index.ts rename src/{utils.ts => utils/url.ts} (97%) diff --git a/package.json b/package.json index 40de6ae..9121931 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,11 @@ "./utils": { "import": { "types": "./dist/esm/utils.d.mts", - "default": "./dist/esm/utils.mjs" + "default": "./dist/esm/utils/index.mjs" }, "require": { "types": "./dist/cjs/utils.d.ts", - "default": "./dist/cjs/utils.js" + "default": "./dist/cjs/utils/index.js" } }, "./package.json": "./package.json" diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..c7bb35f --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from './url' diff --git a/src/utils.ts b/src/utils/url.ts similarity index 97% rename from src/utils.ts rename to src/utils/url.ts index aaf94a1..d84279d 100644 --- a/src/utils.ts +++ b/src/utils/url.ts @@ -1,4 +1,4 @@ -import NURL from './nurl' +import NURL from '../nurl' const DYNAMIC_PATH_COLON_REGEXP = /^:/ const DYNAMIC_PATH_BRACKETS_REGEXP = /^\[.*\]$/ @@ -89,8 +89,8 @@ export function convertQueryToArray(query: Query): string[][] { * @param {string} pathname * @returns {string} path priority representation * - * @example /user/:id/profile -> 212 - * @example /user/admin/:tab -> 221 + * @example getPathPriority('/user/:id/profile') -> '212' + * @example getPathPriority('/user/admin/:tab') -> '221' */ export function getPathPriority(pathname: string): string { const segments = pathname.split('/').filter(Boolean) diff --git a/vite.config.mts b/vite.config.mts index 8693cb8..2ebc1d1 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -9,7 +9,7 @@ export default createViteConfig({ cwd: __dirname, entry: { index: './src/index.ts', - utils: './src/utils.ts', + utils: './src/utils/index.ts', }, outputs: [ {format: 'es', dist: 'dist/esm'}, From 0860f27f585f216a67a45341b5122f15ccd7dbe4 Mon Sep 17 00:00:00 2001 From: chanhwi Date: Wed, 31 Dec 2025 13:53:38 +0900 Subject: [PATCH 13/13] =?UTF-8?q?=F0=9F=92=A1=20add=20descriptions=20for?= =?UTF-8?q?=20match=20and=20mask=20utility=20function=20and=20related=20ty?= =?UTF-8?q?pe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/url.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/utils/url.ts b/src/utils/url.ts index d84279d..e008865 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -98,6 +98,12 @@ export function getPathPriority(pathname: string): string { return segments.map((segment) => (isDynamicPath(segment) ? '1' : '2')).join('') } +/** + * Match a URL against a pattern and extract dynamic parameters. + * @param {string} url - The URL to match. + * @param {string} pattern - The pattern to match against. + * @returns {Record | null} - An object containing the extracted parameters or null if no match. + */ export const match = (url: string, pattern: string): Record | null => { if (!NURL.canParse(url) || !NURL.canParse(pattern)) { return null @@ -128,13 +134,24 @@ export const match = (url: string, pattern: string): Record | nu } export interface MaskOptions { + /** Patterns to match against the URL pathname */ patterns: string[] + /** Sensitive parameters to mask */ sensitiveParams: string[] + /** Character used for masking (default: '*') */ maskChar?: string + /** Length of the mask (default: 4) */ maskLength?: number + /** Whether to preserve the length of the sensitive value when masking (default: false) */ preserveLength?: boolean } +/** + * Masks sensitive parameters in a URL based on provided options. + * @param {string} url - The URL to mask. + * @param {MaskOptions} options - The masking options. + * @returns {string} - The masked URL. + */ export const mask = ( url: string, {patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions,