-
-
Notifications
You must be signed in to change notification settings - Fork 14
feat: add express.static dotfiles codemod #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
bjohansebas
merged 8 commits into
expressjs:main
from
singhvishalkr:feat/static-dotfiles-codemod
Jun 17, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
3c4ce9a
feat: add express.static dotfiles codemod
singhvishalkr a51feb2
feat(static-dotfiles): resolve express aliases
singhvishalkr 559f3d9
chore(static-dotfiles): bump codemod version to 1.1.0
singhvishalkr a1a6b47
fix(static-dotfiles): resolve express aliases without regex
singhvishalkr 10d5282
test: keep codemod fixtures LF on Windows
singhvishalkr 06857c1
docs(static-dotfiles): clarify examples with diffs
singhvishalkr 287d376
feat(static-dotfiles): migrate express.static options to Express 5, u…
bjohansebas 37393f1
fixup!
bjohansebas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| * text=auto eol=lf |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| # Migrate `express.static` options | ||
|
|
||
| Express 5 changes several `express.static` options: | ||
|
|
||
| - 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 | ||
|
|
||
| ```diff | ||
| - app.use(express.static('public')) | ||
| + app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) | ||
| ``` | ||
|
|
||
| ### With existing options | ||
|
|
||
| ```diff | ||
| - app.use(express.static('public', { maxAge: '1d' })) | ||
| + 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: | ||
|
|
||
| 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#expressstatic-options) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| schema_version: "1.0" | ||
| name: "@expressjs/static-dotfiles" | ||
| 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 | ||
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| { | ||
| "name": "@expressjs/static-dotfiles", | ||
| "private": true, | ||
| "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 ./" | ||
| }, | ||
| "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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| import type Js from '@codemod.com/jssg-types/src/langs/javascript' | ||
| 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<Js>): Promise<string | null> { | ||
| const rootNode = root.root() | ||
| const edits: Edit[] = [] | ||
|
|
||
| const nodes = rootNode.findAll({ | ||
| rule: { | ||
| any: [{ pattern: '$CALL.static($PATH)' }, { pattern: '$CALL.static($PATH, $OPTS)' }], | ||
| }, | ||
| }) | ||
|
|
||
| if (!nodes.length) return null | ||
|
|
||
| for (const call of nodes) { | ||
| const target = call.getMatch('CALL') | ||
| const pathArg = call.getMatch('PATH') | ||
| const optsArg = call.getMatch('OPTS') | ||
|
|
||
| if (!target || !pathArg) continue | ||
|
|
||
| if (!isExpressBinding(target)) continue | ||
|
|
||
| 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 | ||
|
|
||
| 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<Js>): TransformedOptions | null { | ||
| if (!optsArg.is('object')) return null | ||
|
|
||
| const edits: Edit[] = [] | ||
| const pairs = optsArg.children().filter((pair: SgNode<Js>) => 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<Js>): 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<Js> | null | undefined): string | null { | ||
| if (!node || !node.is('string')) return null | ||
|
|
||
| const text = node.text() | ||
| if (text.length < 2) return null | ||
|
|
||
| return text.slice(1, -1) | ||
| } | ||
|
|
||
| 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).trimEnd() | ||
| const closingIndent = getIndentAfterLastNewline(trimmed) | ||
| const propertyIndent = getIndentAfterLastNewline(body) || ' ' | ||
|
|
||
| return `${body}\n${propertyIndent}${DOTFILES_OPTION}\n${closingIndent}}` | ||
| } | ||
|
|
||
| function isExpressBinding(binding: SgNode<Js>): 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<Js>): 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<Js>): boolean { | ||
| const source = importStatement.field('source') | ||
| return getStringLiteralValue(source) === 'express' | ||
| } | ||
|
|
||
| function isExpressRequireDeclarator(declarator: SgNode<Js>): 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<Js>): 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<Js>, kind: string): SgNode<Js> | null { | ||
| let current: SgNode<Js> | null = node | ||
|
|
||
| while (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() | ||
| } | ||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import staticExpress from "express"; | ||
| import * as expressNS from "express"; | ||
| import otherLib from "other-lib"; | ||
|
|
||
| const expressRequire = require("express"); | ||
|
|
||
| const aliasedExpress = staticExpress; | ||
|
|
||
| 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( | ||
| 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')); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.