From 3c4ce9aa270fa66ff80df84c2be9200c45984216 Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Sat, 16 May 2026 12:25:02 +0530 Subject: [PATCH 1/8] feat: add express.static dotfiles codemod Add a codemod that explicitly sets dotfiles: 'allow' on express.static() calls to preserve Express 4 behavior where dotfiles were served by default. In Express 5, the dotfiles option defaults to 'ignore', which can break functionality that depends on serving dot-directories like .well-known (used by Android App Links and Apple Universal Links). Closes expressjs/codemod#116 --- codemods/static-dotfiles/README.md | 51 ++++++++++++++++++ codemods/static-dotfiles/codemod.yaml | 26 +++++++++ codemods/static-dotfiles/package.json | 22 ++++++++ codemods/static-dotfiles/src/workflow.ts | 53 +++++++++++++++++++ .../static-dotfiles/tests/expected/static.ts | 22 ++++++++ .../static-dotfiles/tests/input/static.ts | 22 ++++++++ codemods/static-dotfiles/workflow.yaml | 28 ++++++++++ codemods/v5-migration-recipe/workflow.yaml | 5 +- 8 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 codemods/static-dotfiles/README.md create mode 100644 codemods/static-dotfiles/codemod.yaml create mode 100644 codemods/static-dotfiles/package.json create mode 100644 codemods/static-dotfiles/src/workflow.ts create mode 100644 codemods/static-dotfiles/tests/expected/static.ts create mode 100644 codemods/static-dotfiles/tests/input/static.ts create mode 100644 codemods/static-dotfiles/workflow.yaml diff --git a/codemods/static-dotfiles/README.md b/codemods/static-dotfiles/README.md new file mode 100644 index 0000000..1f602ef --- /dev/null +++ b/codemods/static-dotfiles/README.md @@ -0,0 +1,51 @@ +# Migrate `express.static` dotfiles behavior + +In Express 5, the `express.static` middleware's `dotfiles` option now defaults to `"ignore"`. This is a change from Express 4, where dotfiles were served by default. As a result, files inside a directory that starts with a dot (`.`), such as `.well-known`, will no longer be accessible and will return a 404 Not Found error. + +This codemod adds an explicit `dotfiles: 'allow'` option to `express.static()` calls that don't already specify a `dotfiles` option, preserving the Express 4 behavior. + +## Example + +### Before + +```javascript +app.use(express.static('public')) +``` + +### After + +```javascript +app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) +``` + +### With existing options + +#### Before + +```javascript +app.use(express.static('public', { maxAge: '1d' })) +``` + +#### After + +```javascript +app.use(express.static('public', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) +``` + +## Security Consideration + +After running this codemod, review each `express.static()` call to determine if serving dotfiles is actually necessary for your application. If you don't need to serve dotfiles, you can: + +1. Remove the `dotfiles: 'allow'` option to use the new Express 5 default (`"ignore"`) +2. Or explicitly set `dotfiles: 'deny'` to return a 403 Forbidden for dotfile requests + +For directories like `.well-known` that need to be served (e.g., for Android App Links or Apple Universal Links), consider serving them explicitly: + +```javascript +app.use('/.well-known', express.static('public/.well-known', { dotfiles: 'allow' })) +app.use(express.static('public')) +``` + +## References + +- [Express 5 Migration Guide - express.static dotfiles](https://expressjs.com/en/guide/migrating-5.html#express.static.dotfiles) diff --git a/codemods/static-dotfiles/codemod.yaml b/codemods/static-dotfiles/codemod.yaml new file mode 100644 index 0000000..8c02d25 --- /dev/null +++ b/codemods/static-dotfiles/codemod.yaml @@ -0,0 +1,26 @@ +schema_version: "1.0" +name: "@expressjs/static-dotfiles" +version: "1.0.0" +description: Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior where dotfiles were served by default +author: Vishal Kumar Singh +license: MIT +workflow: workflow.yaml +repository: "https://github.com/expressjs/codemod/tree/HEAD/codemods/static-dotfiles" +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - express + - static + - dotfiles + - express.static + +registry: + access: public + visibility: public diff --git a/codemods/static-dotfiles/package.json b/codemods/static-dotfiles/package.json new file mode 100644 index 0000000..acd941c --- /dev/null +++ b/codemods/static-dotfiles/package.json @@ -0,0 +1,22 @@ +{ + "name": "@expressjs/static-dotfiles", + "private": true, + "version": "1.0.0", + "description": "Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior where dotfiles were served by default", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/expressjs/codemod.git", + "directory": "codemods/static-dotfiles", + "bugs": "https://github.com/expressjs/codemod/issues" + }, + "author": "Vishal Kumar Singh", + "license": "MIT", + "homepage": "https://github.com/expressjs/codemod/blob/main/codemods/static-dotfiles/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + } +} diff --git a/codemods/static-dotfiles/src/workflow.ts b/codemods/static-dotfiles/src/workflow.ts new file mode 100644 index 0000000..04dae74 --- /dev/null +++ b/codemods/static-dotfiles/src/workflow.ts @@ -0,0 +1,53 @@ +import type Js from '@codemod.com/jssg-types/src/langs/javascript' +import type { Edit, SgRoot } from '@codemod.com/jssg-types/src/main' + +async function transform(root: SgRoot): Promise { + const rootNode = root.root() + + const nodes = rootNode.findAll({ + rule: { + any: [ + { pattern: 'express.static($PATH)' }, + { pattern: 'express.static($PATH, $OPTS)' }, + ], + }, + }) + + if (!nodes.length) return null + + const edits: Edit[] = [] + + for (const call of nodes) { + const pathArg = call.getMatch('PATH') + const optsArg = call.getMatch('OPTS') + + if (!pathArg) continue + + if (optsArg) { + const optsText = optsArg.text() + if (optsText.includes('dotfiles')) { + continue + } + + if (optsText.startsWith('{') && optsText.endsWith('}')) { + const inner = optsText.slice(1, -1).trim() + const newOpts = inner + ? `{ ${inner}, dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }` + : `{ dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }` + edits.push(call.replace(`express.static(${pathArg.text()}, ${newOpts})`)) + } + } else { + edits.push( + call.replace( + `express.static(${pathArg.text()}, { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })` + ) + ) + } + } + + if (!edits.length) return null + + return rootNode.commitEdits(edits) +} + +export default transform diff --git a/codemods/static-dotfiles/tests/expected/static.ts b/codemods/static-dotfiles/tests/expected/static.ts new file mode 100644 index 0000000..3abfb93 --- /dev/null +++ b/codemods/static-dotfiles/tests/expected/static.ts @@ -0,0 +1,22 @@ +import express from "express"; + +const app = express(); + +app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('assets', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use('/files', express.static('uploads', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('public', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('public', { index: false, maxAge: 86400000, dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('public', { dotfiles: 'deny' })); + +app.use(express.static('public', { dotfiles: 'allow', maxAge: '1d' })); + +const staticPath = './static'; +app.use(express.static(staticPath, { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static(__dirname + '/public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); diff --git a/codemods/static-dotfiles/tests/input/static.ts b/codemods/static-dotfiles/tests/input/static.ts new file mode 100644 index 0000000..fb64cac --- /dev/null +++ b/codemods/static-dotfiles/tests/input/static.ts @@ -0,0 +1,22 @@ +import express from "express"; + +const app = express(); + +app.use(express.static('public')); + +app.use(express.static('assets')); + +app.use('/files', express.static('uploads')); + +app.use(express.static('public', { maxAge: '1d' })); + +app.use(express.static('public', { index: false, maxAge: 86400000 })); + +app.use(express.static('public', { dotfiles: 'deny' })); + +app.use(express.static('public', { dotfiles: 'allow', maxAge: '1d' })); + +const staticPath = './static'; +app.use(express.static(staticPath)); + +app.use(express.static(__dirname + '/public')); diff --git a/codemods/static-dotfiles/workflow.yaml b/codemods/static-dotfiles/workflow.yaml new file mode 100644 index 0000000..7a2ecd1 --- /dev/null +++ b/codemods/static-dotfiles/workflow.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior + js-ast-grep: + js_file: src/workflow.ts + base_path: . + semantic_analysis: file + include: + - "**/*.cjs" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript diff --git a/codemods/v5-migration-recipe/workflow.yaml b/codemods/v5-migration-recipe/workflow.yaml index 77ae2fd..dbdbd28 100644 --- a/codemods/v5-migration-recipe/workflow.yaml +++ b/codemods/v5-migration-recipe/workflow.yaml @@ -29,4 +29,7 @@ nodes: source: "@expressjs/camelcase-sendfile" - name: Migrates usage of the legacy APIs `app.del()` to `app.delete()` codemod: - source: "@expressjs/route-del-to-delete" \ No newline at end of file + source: "@expressjs/route-del-to-delete" + - name: Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior + codemod: + source: "@expressjs/static-dotfiles" \ No newline at end of file From a51feb28c99d33893917535980dc5dae78f2ebee Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Mon, 25 May 2026 22:40:54 +0530 Subject: [PATCH 2/8] feat(static-dotfiles): resolve express aliases Detect default, namespace, and CommonJS express bindings before rewriting static() calls. Add a multiline fixture to cover indentation-sensitive options objects. --- codemods/static-dotfiles/src/workflow.ts | 73 ++++++++++++++----- .../static-dotfiles/tests/expected/aliases.ts | 24 ++++++ .../static-dotfiles/tests/input/aliases.ts | 23 ++++++ package-lock.json | 12 +++ 4 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 codemods/static-dotfiles/tests/expected/aliases.ts create mode 100644 codemods/static-dotfiles/tests/input/aliases.ts diff --git a/codemods/static-dotfiles/src/workflow.ts b/codemods/static-dotfiles/src/workflow.ts index 04dae74..ae940a0 100644 --- a/codemods/static-dotfiles/src/workflow.ts +++ b/codemods/static-dotfiles/src/workflow.ts @@ -1,15 +1,15 @@ import type Js from '@codemod.com/jssg-types/src/langs/javascript' import type { Edit, SgRoot } from '@codemod.com/jssg-types/src/main' +const DOTFILES_OPTION = "dotfiles: 'allow' /* Express 5: preserve v4 behavior */" + async function transform(root: SgRoot): Promise { const rootNode = root.root() + const expressBindings = collectExpressBindings(rootNode.text()) const nodes = rootNode.findAll({ rule: { - any: [ - { pattern: 'express.static($PATH)' }, - { pattern: 'express.static($PATH, $OPTS)' }, - ], + any: [{ pattern: '$CALL.static($PATH)' }, { pattern: '$CALL.static($PATH, $OPTS)' }], }, }) @@ -18,10 +18,14 @@ async function transform(root: SgRoot): Promise { const edits: Edit[] = [] for (const call of nodes) { + const target = call.getMatch('CALL') const pathArg = call.getMatch('PATH') const optsArg = call.getMatch('OPTS') - if (!pathArg) continue + if (!target || !pathArg) continue + + const targetText = target.text() + if (!isExpressBinding(targetText, expressBindings)) continue if (optsArg) { const optsText = optsArg.text() @@ -29,19 +33,10 @@ async function transform(root: SgRoot): Promise { continue } - if (optsText.startsWith('{') && optsText.endsWith('}')) { - const inner = optsText.slice(1, -1).trim() - const newOpts = inner - ? `{ ${inner}, dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }` - : `{ dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }` - edits.push(call.replace(`express.static(${pathArg.text()}, ${newOpts})`)) - } + const newOpts = addDotfilesOption(optsText) + edits.push(call.replace(call.text().replace(optsText, newOpts))) } else { - edits.push( - call.replace( - `express.static(${pathArg.text()}, { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })` - ) - ) + edits.push(call.replace(`${targetText}.static(${pathArg.text()}, { ${DOTFILES_OPTION} })`)) } } @@ -50,4 +45,48 @@ async function transform(root: SgRoot): Promise { return rootNode.commitEdits(edits) } +function collectExpressBindings(source: string): Set { + const bindings = new Set(['express']) + + const defaultImportPattern = /import\s+([A-Za-z_$][\w$]*)(?:\s*,[\s\S]*?)?\s+from\s+['"]express['"]/g + const namespaceImportPattern = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]express['"]/g + const defaultAsImportPattern = /import\s+\{\s*default\s+as\s+([A-Za-z_$][\w$]*)[\s\S]*?\}\s+from\s+['"]express['"]/g + const requirePattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"]express['"]\s*\)/g + + const patterns = [defaultImportPattern, namespaceImportPattern, defaultAsImportPattern, requirePattern] + + for (const pattern of patterns) { + let match = pattern.exec(source) + while (match !== null) { + bindings.add(match[1]) + match = pattern.exec(source) + } + } + + return bindings +} + +function addDotfilesOption(optsText: string): string { + const trimmed = optsText.trimEnd() + + if (!trimmed.includes('\n')) { + const inner = trimmed.slice(1, -1).trim() + + return inner ? `{ ${inner}, ${DOTFILES_OPTION} }` : `{ ${DOTFILES_OPTION} }` + } + + const closingBraceIndex = trimmed.lastIndexOf('}') + const body = trimmed.slice(0, closingBraceIndex).replace(/\s*$/, '') + const closingIndentMatch = trimmed.match(/\n([ \t]*)\}$/) + const closingIndent = closingIndentMatch?.[1] ?? '' + const propertyIndentMatch = body.match(/\n([ \t]*)[^\n]*$/) + const propertyIndent = propertyIndentMatch?.[1] ?? ' ' + + return `${body}\n${propertyIndent}${DOTFILES_OPTION}\n${closingIndent}}` +} + +function isExpressBinding(binding: string, expressBindings: Set): boolean { + return expressBindings.has(binding) || /^require\(\s*['"]express['"]\s*\)$/.test(binding) +} + export default transform diff --git a/codemods/static-dotfiles/tests/expected/aliases.ts b/codemods/static-dotfiles/tests/expected/aliases.ts new file mode 100644 index 0000000..7b52c60 --- /dev/null +++ b/codemods/static-dotfiles/tests/expected/aliases.ts @@ -0,0 +1,24 @@ +import staticExpress from "express"; +import * as expressNS from "express"; + +const expressRequire = require("express"); + +const app = { + use() {}, +}; + +app.use(staticExpress.static('aliased', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use( + staticExpress.static( + 'multi-line', + { + maxAge: '1d', + dotfiles: 'allow' /* Express 5: preserve v4 behavior */ + } + ) +); + +app.use(expressNS.static('namespace', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(expressRequire.static('commonjs', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); diff --git a/codemods/static-dotfiles/tests/input/aliases.ts b/codemods/static-dotfiles/tests/input/aliases.ts new file mode 100644 index 0000000..bc92258 --- /dev/null +++ b/codemods/static-dotfiles/tests/input/aliases.ts @@ -0,0 +1,23 @@ +import staticExpress from "express"; +import * as expressNS from "express"; + +const expressRequire = require("express"); + +const app = { + use() {}, +}; + +app.use(staticExpress.static('aliased')); + +app.use( + staticExpress.static( + 'multi-line', + { + maxAge: '1d', + } + ) +); + +app.use(expressNS.static('namespace')); + +app.use(expressRequire.static('commonjs')); diff --git a/package-lock.json b/package-lock.json index 2d9f155..4f0b8c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,14 @@ "@codemod.com/jssg-types": "^1.5.0" } }, + "codemods/static-dotfiles": { + "name": "@expressjs/static-dotfiles", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + } + }, "codemods/status-send-order": { "name": "@expressjs/status-send-order", "version": "1.0.0", @@ -278,6 +286,10 @@ "resolved": "codemods/route-del-to-delete", "link": true }, + "node_modules/@expressjs/static-dotfiles": { + "resolved": "codemods/static-dotfiles", + "link": true + }, "node_modules/@expressjs/status-send-order": { "resolved": "codemods/status-send-order", "link": true From 559f3d9d959f2efe6ae5dd6c422f8f14a4e5e276 Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Mon, 25 May 2026 22:41:47 +0530 Subject: [PATCH 3/8] chore(static-dotfiles): bump codemod version to 1.1.0 Match the reviewer request to bump the codemod minor version alongside the alias-handling fix. --- codemods/static-dotfiles/package.json | 2 +- package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codemods/static-dotfiles/package.json b/codemods/static-dotfiles/package.json index acd941c..395de9f 100644 --- a/codemods/static-dotfiles/package.json +++ b/codemods/static-dotfiles/package.json @@ -1,7 +1,7 @@ { "name": "@expressjs/static-dotfiles", "private": true, - "version": "1.0.0", + "version": "1.1.0", "description": "Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior where dotfiles were served by default", "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index 4f0b8c4..e6d7b1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,7 @@ }, "codemods/static-dotfiles": { "name": "@expressjs/static-dotfiles", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@codemod.com/jssg-types": "^1.5.0" From a1a6b47c23b58688ee716be1f57aae34e053d1ab Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Mon, 25 May 2026 23:03:17 +0530 Subject: [PATCH 4/8] fix(static-dotfiles): resolve express aliases without regex --- codemods/static-dotfiles/src/workflow.ts | 113 +++++++++++++----- .../static-dotfiles/tests/expected/aliases.ts | 2 + .../static-dotfiles/tests/input/aliases.ts | 2 + 3 files changed, 88 insertions(+), 29 deletions(-) diff --git a/codemods/static-dotfiles/src/workflow.ts b/codemods/static-dotfiles/src/workflow.ts index ae940a0..33eb9df 100644 --- a/codemods/static-dotfiles/src/workflow.ts +++ b/codemods/static-dotfiles/src/workflow.ts @@ -1,11 +1,10 @@ import type Js from '@codemod.com/jssg-types/src/langs/javascript' -import type { Edit, SgRoot } from '@codemod.com/jssg-types/src/main' +import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/src/main' const DOTFILES_OPTION = "dotfiles: 'allow' /* Express 5: preserve v4 behavior */" async function transform(root: SgRoot): Promise { const rootNode = root.root() - const expressBindings = collectExpressBindings(rootNode.text()) const nodes = rootNode.findAll({ rule: { @@ -24,8 +23,7 @@ async function transform(root: SgRoot): Promise { if (!target || !pathArg) continue - const targetText = target.text() - if (!isExpressBinding(targetText, expressBindings)) continue + if (!isExpressBinding(target)) continue if (optsArg) { const optsText = optsArg.text() @@ -36,7 +34,7 @@ async function transform(root: SgRoot): Promise { const newOpts = addDotfilesOption(optsText) edits.push(call.replace(call.text().replace(optsText, newOpts))) } else { - edits.push(call.replace(`${targetText}.static(${pathArg.text()}, { ${DOTFILES_OPTION} })`)) + edits.push(call.replace(`${target.text()}.static(${pathArg.text()}, { ${DOTFILES_OPTION} })`)) } } @@ -45,25 +43,13 @@ async function transform(root: SgRoot): Promise { return rootNode.commitEdits(edits) } -function collectExpressBindings(source: string): Set { - const bindings = new Set(['express']) +function getStringLiteralValue(node: SgNode | null | undefined): string | null { + if (!node || !node.is('string')) return null - const defaultImportPattern = /import\s+([A-Za-z_$][\w$]*)(?:\s*,[\s\S]*?)?\s+from\s+['"]express['"]/g - const namespaceImportPattern = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]express['"]/g - const defaultAsImportPattern = /import\s+\{\s*default\s+as\s+([A-Za-z_$][\w$]*)[\s\S]*?\}\s+from\s+['"]express['"]/g - const requirePattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"]express['"]\s*\)/g + const text = node.text() + if (text.length < 2) return null - const patterns = [defaultImportPattern, namespaceImportPattern, defaultAsImportPattern, requirePattern] - - for (const pattern of patterns) { - let match = pattern.exec(source) - while (match !== null) { - bindings.add(match[1]) - match = pattern.exec(source) - } - } - - return bindings + return text.slice(1, -1) } function addDotfilesOption(optsText: string): string { @@ -76,17 +62,86 @@ function addDotfilesOption(optsText: string): string { } const closingBraceIndex = trimmed.lastIndexOf('}') - const body = trimmed.slice(0, closingBraceIndex).replace(/\s*$/, '') - const closingIndentMatch = trimmed.match(/\n([ \t]*)\}$/) - const closingIndent = closingIndentMatch?.[1] ?? '' - const propertyIndentMatch = body.match(/\n([ \t]*)[^\n]*$/) - const propertyIndent = propertyIndentMatch?.[1] ?? ' ' + const body = trimmed.slice(0, closingBraceIndex).trimEnd() + const closingIndent = getIndentAfterLastNewline(trimmed) + const propertyIndent = getIndentAfterLastNewline(body) || ' ' return `${body}\n${propertyIndent}${DOTFILES_OPTION}\n${closingIndent}}` } -function isExpressBinding(binding: string, expressBindings: Set): boolean { - return expressBindings.has(binding) || /^require\(\s*['"]express['"]\s*\)$/.test(binding) +function isExpressBinding(binding: SgNode): boolean { + if (binding.is('call_expression')) { + return isExpressRequireCall(binding) + } + + const definition = binding.definition({ resolveExternal: false }) + if (!definition) return false + + return isExpressDefinition(definition.node) +} + +function isExpressDefinition(node: SgNode): boolean { + const importStatement = findAncestorOrSelf(node, 'import_statement') + if (importStatement) { + return isExpressImport(importStatement) + } + + const declarator = findAncestorOrSelf(node, 'variable_declarator') + if (declarator) { + return isExpressRequireDeclarator(declarator) + } + + return false +} + +function isExpressImport(importStatement: SgNode): boolean { + const source = importStatement.field('source') + return getStringLiteralValue(source) === 'express' +} + +function isExpressRequireDeclarator(declarator: SgNode): boolean { + if (!declarator.is('variable_declarator')) return false + + const value = declarator.field('value') + if (!value?.is('call_expression')) return false + + return isExpressRequireCall(value) +} + +function isExpressRequireCall(node: SgNode): boolean { + const callFunction = node.field('function') + if (!callFunction?.is('identifier') || callFunction.text() !== 'require') return false + + const args = node.field('arguments') + if (!args) return false + + const expressSource = args.children().find((child) => child.is('string')) + return getStringLiteralValue(expressSource) === 'express' +} + +function findAncestorOrSelf(node: SgNode, kind: string): SgNode | null { + let current: SgNode | null = node + + while (current) { + if (current.is(kind)) return current + current = current.parent() + } + + return null +} + +function getIndentAfterLastNewline(text: string): string { + const newlineIndex = text.lastIndexOf('\n') + if (newlineIndex === -1) return '' + + let indent = '' + for (let index = newlineIndex + 1; index < text.length; index++) { + const char = text[index] + if (char !== ' ' && char !== '\t') break + indent += char + } + + return indent } export default transform diff --git a/codemods/static-dotfiles/tests/expected/aliases.ts b/codemods/static-dotfiles/tests/expected/aliases.ts index 7b52c60..311798f 100644 --- a/codemods/static-dotfiles/tests/expected/aliases.ts +++ b/codemods/static-dotfiles/tests/expected/aliases.ts @@ -22,3 +22,5 @@ app.use( app.use(expressNS.static('namespace', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); app.use(expressRequire.static('commonjs', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(require("express").static('direct-require', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); diff --git a/codemods/static-dotfiles/tests/input/aliases.ts b/codemods/static-dotfiles/tests/input/aliases.ts index bc92258..5c920cc 100644 --- a/codemods/static-dotfiles/tests/input/aliases.ts +++ b/codemods/static-dotfiles/tests/input/aliases.ts @@ -21,3 +21,5 @@ app.use( app.use(expressNS.static('namespace')); app.use(expressRequire.static('commonjs')); + +app.use(require("express").static('direct-require')); From 10d5282f1f52c44ca47e7b5bc3539c9a20167dde Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Thu, 11 Jun 2026 21:49:21 +0530 Subject: [PATCH 5/8] test: keep codemod fixtures LF on Windows Force text checkouts to LF so ast-grep fixture comparisons stay stable on Windows runners. This fixes the static-dotfiles multiline fixture mismatch without changing the transform logic. Signed-off-by: Vishal Kumar Singh --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf From 06857c16ec256d32c60c0a104cecf260d4b5106c Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Tue, 16 Jun 2026 22:00:29 +0530 Subject: [PATCH 6/8] docs(static-dotfiles): clarify examples with diffs --- codemods/static-dotfiles/README.md | 26 ++++++------------------ codemods/static-dotfiles/src/workflow.ts | 3 +-- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/codemods/static-dotfiles/README.md b/codemods/static-dotfiles/README.md index 1f602ef..38d775f 100644 --- a/codemods/static-dotfiles/README.md +++ b/codemods/static-dotfiles/README.md @@ -6,30 +6,16 @@ This codemod adds an explicit `dotfiles: 'allow'` option to `express.static()` c ## Example -### Before - -```javascript -app.use(express.static('public')) -``` - -### After - -```javascript -app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) +```diff +- app.use(express.static('public')) ++ app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) ``` ### With existing options -#### Before - -```javascript -app.use(express.static('public', { maxAge: '1d' })) -``` - -#### After - -```javascript -app.use(express.static('public', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) +```diff +- app.use(express.static('public', { maxAge: '1d' })) ++ app.use(express.static('public', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) ``` ## Security Consideration diff --git a/codemods/static-dotfiles/src/workflow.ts b/codemods/static-dotfiles/src/workflow.ts index 33eb9df..993c9eb 100644 --- a/codemods/static-dotfiles/src/workflow.ts +++ b/codemods/static-dotfiles/src/workflow.ts @@ -5,6 +5,7 @@ const DOTFILES_OPTION = "dotfiles: 'allow' /* Express 5: preserve v4 behavior */ async function transform(root: SgRoot): Promise { const rootNode = root.root() + const edits: Edit[] = [] const nodes = rootNode.findAll({ rule: { @@ -14,8 +15,6 @@ async function transform(root: SgRoot): Promise { if (!nodes.length) return null - const edits: Edit[] = [] - for (const call of nodes) { const target = call.getMatch('CALL') const pathArg = call.getMatch('PATH') From 287d3766bcfd6629c613fe04a61069b2efee5490 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 16 Jun 2026 21:20:03 -0500 Subject: [PATCH 7/8] feat(static-dotfiles): migrate express.static options to Express 5, updating dotfiles behavior and renaming removed options --- codemods/static-dotfiles/README.md | 28 +++++- codemods/static-dotfiles/codemod.yaml | 2 +- codemods/static-dotfiles/package.json | 4 +- codemods/static-dotfiles/src/workflow.ts | 97 +++++++++++++++++-- .../static-dotfiles/tests/expected/aliases.ts | 19 ++++ .../static-dotfiles/tests/expected/static.ts | 19 ++++ .../static-dotfiles/tests/input/aliases.ts | 19 ++++ .../static-dotfiles/tests/input/static.ts | 19 ++++ 8 files changed, 191 insertions(+), 16 deletions(-) diff --git a/codemods/static-dotfiles/README.md b/codemods/static-dotfiles/README.md index 38d775f..31849b8 100644 --- a/codemods/static-dotfiles/README.md +++ b/codemods/static-dotfiles/README.md @@ -1,8 +1,16 @@ -# Migrate `express.static` dotfiles behavior +# Migrate `express.static` options -In Express 5, the `express.static` middleware's `dotfiles` option now defaults to `"ignore"`. This is a change from Express 4, where dotfiles were served by default. As a result, files inside a directory that starts with a dot (`.`), such as `.well-known`, will no longer be accessible and will return a 404 Not Found error. +Express 5 changes several `express.static` options: -This codemod adds an explicit `dotfiles: 'allow'` option to `express.static()` calls that don't already specify a `dotfiles` option, preserving the Express 4 behavior. +- The `dotfiles` option now defaults to `"ignore"` (Express 4 served dotfiles by default). Files inside a directory that starts with a dot (`.`), such as `.well-known`, will no longer be accessible and will return a 404 Not Found error. +- The `hidden` option is removed and replaced by `dotfiles`. +- The `from` option (an undocumented alias for `root`) is removed and replaced by `root`. + +This codemod updates `express.static()` calls to preserve the Express 4 behavior: + +1. Adds an explicit `dotfiles: 'allow'` option to calls that don't already specify a `dotfiles` (or `hidden`) option. +2. Renames `hidden` to `dotfiles` (`hidden: true` → `dotfiles: 'allow'`, `hidden: false` → `dotfiles: 'ignore'`). +3. Renames `from` to `root`. ## Example @@ -18,6 +26,20 @@ This codemod adds an explicit `dotfiles: 'allow'` option to `express.static()` c + app.use(express.static('public', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) ``` +### Removed `hidden` option + +```diff +- app.use(express.static('public', { hidden: true })) ++ app.use(express.static('public', { dotfiles: 'allow' })) +``` + +### Removed `from` option + +```diff +- app.use(express.static('uploads', { from: '/uploads' })) ++ app.use(express.static('uploads', { root: '/uploads', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) +``` + ## Security Consideration After running this codemod, review each `express.static()` call to determine if serving dotfiles is actually necessary for your application. If you don't need to serve dotfiles, you can: diff --git a/codemods/static-dotfiles/codemod.yaml b/codemods/static-dotfiles/codemod.yaml index 8c02d25..3580cbb 100644 --- a/codemods/static-dotfiles/codemod.yaml +++ b/codemods/static-dotfiles/codemod.yaml @@ -1,7 +1,7 @@ schema_version: "1.0" name: "@expressjs/static-dotfiles" version: "1.0.0" -description: Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior where dotfiles were served by default +description: Migrates express.static() options to Express 5 - adds an explicit dotfiles option and renames the removed hidden and from options author: Vishal Kumar Singh license: MIT workflow: workflow.yaml diff --git a/codemods/static-dotfiles/package.json b/codemods/static-dotfiles/package.json index 395de9f..5dea0fe 100644 --- a/codemods/static-dotfiles/package.json +++ b/codemods/static-dotfiles/package.json @@ -1,8 +1,8 @@ { "name": "@expressjs/static-dotfiles", "private": true, - "version": "1.1.0", - "description": "Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior where dotfiles were served by default", + "version": "1.0.0", + "description": "Migrates express.static() options to Express 5: adds an explicit dotfiles option and renames the removed hidden and from options", "type": "module", "scripts": { "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" diff --git a/codemods/static-dotfiles/src/workflow.ts b/codemods/static-dotfiles/src/workflow.ts index 993c9eb..e4f74e3 100644 --- a/codemods/static-dotfiles/src/workflow.ts +++ b/codemods/static-dotfiles/src/workflow.ts @@ -24,17 +24,25 @@ async function transform(root: SgRoot): Promise { if (!isExpressBinding(target)) continue - if (optsArg) { - const optsText = optsArg.text() - if (optsText.includes('dotfiles')) { - continue - } - - const newOpts = addDotfilesOption(optsText) - edits.push(call.replace(call.text().replace(optsText, newOpts))) - } else { + if (!optsArg) { edits.push(call.replace(`${target.text()}.static(${pathArg.text()}, { ${DOTFILES_OPTION} })`)) + continue } + + const result = transformOptions(optsArg) + // Skip anything that isn't an object literal (e.g. a variable reference); + // we can't safely rewrite options we can't see. + if (!result) continue + + let newOpts = result.text + if (!result.hasDotfiles) { + newOpts = addDotfilesOption(newOpts) + } + + const originalOpts = optsArg.text() + if (newOpts === originalOpts) continue + + edits.push(call.replace(call.text().replace(originalOpts, newOpts))) } if (!edits.length) return null @@ -42,6 +50,71 @@ async function transform(root: SgRoot): Promise { return rootNode.commitEdits(edits) } +interface TransformedOptions { + text: string + // True when the resulting object already carries a `dotfiles` key (either + // present originally or produced by renaming a removed `hidden` option), so + // the default `dotfiles: 'allow'` should NOT be appended. + hasDotfiles: boolean +} + +// Rewrites the options object for Express 5: +// - renames the removed `hidden` option to `dotfiles` (true -> 'allow', false -> 'ignore') +// - renames the removed `from` option to `root` +// Returns null when the argument isn't an object literal. +function transformOptions(optsArg: SgNode): TransformedOptions | null { + if (!optsArg.is('object')) return null + + const edits: Edit[] = [] + const pairs = optsArg.children().filter((pair: SgNode) => pair.is('pair')) + + // An explicit `dotfiles` key always wins: we never append a default and never + // rename a `hidden` onto it (which would produce a duplicate `dotfiles` key). + const hasExplicitDotfiles = pairs.some((pair) => getOptionKeyName(pair) === 'dotfiles') + + // A present `hidden` (even non-literal) maps onto `dotfiles`, so a default + // must not be appended even when we can't rewrite its value. + let hasDotfiles = hasExplicitDotfiles + + for (const pair of pairs) { + const keyName = getOptionKeyName(pair) + + if (keyName === 'hidden') { + hasDotfiles = true + + if (hasExplicitDotfiles) continue + + const valueNode = pair.field('value') + const mapped = valueNode ? mapHiddenValue(valueNode.text()) : null + if (mapped) edits.push(pair.replace(`dotfiles: ${mapped}`)) + continue + } + + if (keyName === 'from') { + const keyNode = pair.field('key') + if (keyNode) edits.push(keyNode.replace('root')) + } + } + + const text = edits.length ? optsArg.commitEdits(edits) : optsArg.text() + return { text, hasDotfiles } +} + +function getOptionKeyName(pair: SgNode): string | null { + const keyNode = pair.field('key') + if (!keyNode) return null + + return keyNode.is('string') ? getStringLiteralValue(keyNode) : keyNode.text() +} + +function mapHiddenValue(valueText: string): string | null { + const trimmed = valueText.trim() + if (trimmed === 'true') return "'allow'" + if (trimmed === 'false') return "'ignore'" + + return null +} + function getStringLiteralValue(node: SgNode | null | undefined): string | null { if (!node || !node.is('string')) return null @@ -122,7 +195,11 @@ function findAncestorOrSelf(node: SgNode, kind: string): SgNode | null { let current: SgNode | null = node while (current) { - if (current.is(kind)) return current + // Assign to a typed boolean so `is()`'s type predicate doesn't narrow + // `current` to `never` on the following line. + const matches: boolean = current.is(kind) + if (matches) return current + current = current.parent() } diff --git a/codemods/static-dotfiles/tests/expected/aliases.ts b/codemods/static-dotfiles/tests/expected/aliases.ts index 311798f..9e1aad6 100644 --- a/codemods/static-dotfiles/tests/expected/aliases.ts +++ b/codemods/static-dotfiles/tests/expected/aliases.ts @@ -1,8 +1,11 @@ import staticExpress from "express"; import * as expressNS from "express"; +import otherLib from "other-lib"; const expressRequire = require("express"); +const aliasedExpress = staticExpress; + const app = { use() {}, }; @@ -19,8 +22,24 @@ app.use( ) ); +app.use( + staticExpress.static( + 'multi-line-options', + { + root: '/uploads', + dotfiles: 'allow', + } + ) +); + app.use(expressNS.static('namespace', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); app.use(expressRequire.static('commonjs', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); app.use(require("express").static('direct-require', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +// Not express: must be left untouched. +app.use(otherLib.static('not-express')); + +// Indirect alias (assignment, not import/require): conservatively left untouched. +app.use(aliasedExpress.static('indirect-alias')); diff --git a/codemods/static-dotfiles/tests/expected/static.ts b/codemods/static-dotfiles/tests/expected/static.ts index 3abfb93..5b907d6 100644 --- a/codemods/static-dotfiles/tests/expected/static.ts +++ b/codemods/static-dotfiles/tests/expected/static.ts @@ -1,6 +1,7 @@ import express from "express"; const app = express(); +const serveHidden = true; app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); @@ -16,6 +17,24 @@ app.use(express.static('public', { dotfiles: 'deny' })); app.use(express.static('public', { dotfiles: 'allow', maxAge: '1d' })); +app.use(express.static('public', { dotfiles: 'allow' })); + +app.use(express.static('public', { dotfiles: 'ignore' })); + +app.use(express.static('public', { dotfiles: 'allow', maxAge: '1d' })); + +app.use(express.static('uploads', { root: '/uploads', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('uploads', { root: '/uploads', maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('public', { dotfiles: 'allow' })); + +app.use(express.static('uploads', { root: '/uploads', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('public', { hidden: true, dotfiles: 'deny' })); + +app.use(express.static('public', { hidden: serveHidden })); + const staticPath = './static'; app.use(express.static(staticPath, { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); diff --git a/codemods/static-dotfiles/tests/input/aliases.ts b/codemods/static-dotfiles/tests/input/aliases.ts index 5c920cc..37b2e18 100644 --- a/codemods/static-dotfiles/tests/input/aliases.ts +++ b/codemods/static-dotfiles/tests/input/aliases.ts @@ -1,8 +1,11 @@ import staticExpress from "express"; import * as expressNS from "express"; +import otherLib from "other-lib"; const expressRequire = require("express"); +const aliasedExpress = staticExpress; + const app = { use() {}, }; @@ -18,8 +21,24 @@ app.use( ) ); +app.use( + staticExpress.static( + 'multi-line-options', + { + from: '/uploads', + hidden: true, + } + ) +); + app.use(expressNS.static('namespace')); app.use(expressRequire.static('commonjs')); app.use(require("express").static('direct-require')); + +// Not express: must be left untouched. +app.use(otherLib.static('not-express')); + +// Indirect alias (assignment, not import/require): conservatively left untouched. +app.use(aliasedExpress.static('indirect-alias')); diff --git a/codemods/static-dotfiles/tests/input/static.ts b/codemods/static-dotfiles/tests/input/static.ts index fb64cac..1c3c5d2 100644 --- a/codemods/static-dotfiles/tests/input/static.ts +++ b/codemods/static-dotfiles/tests/input/static.ts @@ -1,6 +1,7 @@ import express from "express"; const app = express(); +const serveHidden = true; app.use(express.static('public')); @@ -16,6 +17,24 @@ app.use(express.static('public', { dotfiles: 'deny' })); app.use(express.static('public', { dotfiles: 'allow', maxAge: '1d' })); +app.use(express.static('public', { hidden: true })); + +app.use(express.static('public', { hidden: false })); + +app.use(express.static('public', { hidden: true, maxAge: '1d' })); + +app.use(express.static('uploads', { from: '/uploads' })); + +app.use(express.static('uploads', { from: '/uploads', maxAge: '1d' })); + +app.use(express.static('public', { 'hidden': true })); + +app.use(express.static('uploads', { "from": '/uploads' })); + +app.use(express.static('public', { hidden: true, dotfiles: 'deny' })); + +app.use(express.static('public', { hidden: serveHidden })); + const staticPath = './static'; app.use(express.static(staticPath)); From 37393f1782f61d99832db4a88a80f9413eff74ae Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 16 Jun 2026 21:24:52 -0500 Subject: [PATCH 8/8] fixup! --- codemods/static-dotfiles/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codemods/static-dotfiles/README.md b/codemods/static-dotfiles/README.md index 31849b8..03c6ca7 100644 --- a/codemods/static-dotfiles/README.md +++ b/codemods/static-dotfiles/README.md @@ -56,4 +56,4 @@ app.use(express.static('public')) ## References -- [Express 5 Migration Guide - express.static dotfiles](https://expressjs.com/en/guide/migrating-5.html#express.static.dotfiles) +- [Express 5 Migration Guide - express.static dotfiles](https://expressjs.com/en/guide/migrating-5#expressstatic-options)