create-express-new 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.3.0] - 2026-10-11
10
+
11
+ ### Added
12
+
13
+ - `--sass` adds Sass to a web app: a `style.scss` compiled to the CSS the app serves by a `css`
14
+ script that runs before `start` and `dev`, a `css:watch` script, and a stage in the `--docker`
15
+ image that compiles the stylesheets. The wizard offers it under Extras
16
+ - Issue templates for bug reports and feature requests
17
+
18
+ ## [1.2.0] - 2026-10-11
19
+
20
+ ### Added
21
+
22
+ - `--api` with a view engine, such as `--api --view=pug`, generates a web app with a JSON API: a
23
+ `routes/api.js` router at `/api`, and JSON responses for every error under `/api`. The wizard offers
24
+ it as "Web app with a JSON API"
25
+ - `--cors-scope` and `--csrf-scope`, for a web app with a JSON API, choose whether other origins can
26
+ call only the API (default) or the whole app, and whether the API needs the CSRF token (default) or
27
+ only the pages do; the wizard asks both
28
+
29
+ ### Changed
30
+
31
+ - `--api` with `--view` is no longer an error, and `--session` and `--csrf` can be used with them
32
+
9
33
  ## [1.1.0] - 2026-10-10
10
34
 
11
35
  ### Added
@@ -121,6 +145,8 @@ the full list and a table for migrating commands.
121
145
  - The `-e/--ejs`, `--hbs`, `--pug` and `-H/--hogan` aliases; use `--view=<engine>`
122
146
  - The `debug` package and `DEBUG=...` start instructions
123
147
 
124
- [Unreleased]: https://github.com/w0244079/create-express-new/compare/v1.1.0...HEAD
148
+ [Unreleased]: https://github.com/w0244079/create-express-new/compare/v1.3.0...HEAD
149
+ [1.3.0]: https://github.com/w0244079/create-express-new/compare/v1.2.0...v1.3.0
150
+ [1.2.0]: https://github.com/w0244079/create-express-new/compare/v1.1.0...v1.2.0
125
151
  [1.1.0]: https://github.com/w0244079/create-express-new/compare/v1.0.0...v1.1.0
126
152
  [1.0.0]: https://github.com/w0244079/create-express-new/releases/tag/v1.0.0
package/README.md CHANGED
@@ -26,11 +26,11 @@ $ pnpm create express-new@latest
26
26
  `npm create express-new@latest` starts the same wizard. It asks for:
27
27
 
28
28
  - the project directory (`.` for the current directory)
29
- - a web app or a JSON API, and the view engine
29
+ - a web app, a JSON API or a web app with a JSON API, and the view engine
30
30
  - the language: JavaScript, TypeScript or CommonJS
31
31
  - optional middleware, and CSRF protection with sessions
32
32
  - the request logger
33
- - extras: a Dockerfile and ESLint
33
+ - extras: a Dockerfile, ESLint and Sass
34
34
  - a `.gitignore`
35
35
  - the [package manager](#package-manager) the app requires: pnpm, or npm
36
36
 
@@ -122,7 +122,40 @@ client errors include their message, server errors only say `Internal Server Err
122
122
  and development adds the stack trace. Express runs in development mode unless `NODE_ENV` is set, so
123
123
  set `NODE_ENV=production` when you deploy.
124
124
 
125
- `--api` works with `--cjs`, `--ts` and the optional middleware, but not with `--view`.
125
+ `--api` works with `--cjs`, `--ts` and the optional middleware, except `--session`.
126
+
127
+ ### Web apps with a JSON API
128
+
129
+ `--api` with a view engine, such as `--api --view=pug`, generates a web app that has a JSON API too:
130
+ everything a web app has, and a `routes/api.js` router mounted at `/api`.
131
+
132
+ ```
133
+ my-app
134
+ ├── app.js # the web app, with JSON errors under /api
135
+ ├── routes/
136
+ │ ├── api.js # JSON routes: GET /api and GET /api/users
137
+ │ ├── index.js # the home page
138
+ │ └── users.js
139
+ ├── views/
140
+ └── ...
141
+ ```
142
+
143
+ Every error of a request under `/api` is JSON, in the same shape as a JSON API's: the 404 of an
144
+ unknown route, an error a route throws, and the errors of the middleware before the routes, such as a
145
+ request body that is not valid JSON. Every other error renders the `error` view.
146
+
147
+ With `--cors` and `--csrf`, you choose which routes they apply to:
148
+
149
+ - `--cors-scope=api` (default) lets other origins call the API only, not read the pages;
150
+ `--cors-scope=all` lets them call the whole app.
151
+ - `--csrf-scope=all` (default) requires the CSRF token under `/api` too, for an API your own pages
152
+ call; `--csrf-scope=pages` leaves the API out, for an API other clients call without the session
153
+ cookie.
154
+
155
+ The wizard asks both.
156
+
157
+ The view engine has to be given, as `--api` alone is a JSON API without views: use `--view=pug` for
158
+ the default one. `--api` does not add an API to a `--no-view` app.
126
159
 
127
160
  ### Module formats
128
161
 
@@ -158,7 +191,8 @@ files with `--api`). Add more with:
158
191
  - `--cors`: [cors](https://github.com/expressjs/cors), allowing requests from other origins
159
192
  - `--rate-limit`: [express-rate-limit](https://express-rate-limit.mintlify.app/), limiting each client
160
193
  to 100 requests every 15 minutes
161
- - `--session` (web apps only): [express-session](https://github.com/expressjs/session), for `req.session`
194
+ - `--session` (not for JSON APIs without views): [express-session](https://github.com/expressjs/session),
195
+ for `req.session`
162
196
  - `--csrf` (with `--session` and a view engine): [csrf-sync](https://github.com/Psifi-Solutions/csrf-sync)
163
197
  CSRF protection for forms
164
198
  - `--uploads`: [multer](https://github.com/expressjs/multer) and a `POST /uploads` route that takes one
@@ -203,6 +237,7 @@ The image:
203
237
  - is based on `node:22-slim`, the latest release of the minimum supported Node.js version
204
238
  - installs only production dependencies, in a build stage of their own, so the package manager is
205
239
  not in the final image
240
+ - with `--sass`, compiles the stylesheets in another build stage, so Sass is not in the final image
206
241
  - runs the app as the unprivileged `node` user with `NODE_ENV=production`
207
242
  - checks `/health` with a `HEALTHCHECK`
208
243
  - runs `node` directly, so it receives `SIGTERM` and shuts down gracefully
@@ -216,22 +251,45 @@ The image:
216
251
  `--ts` it also adds [typescript-eslint](https://typescript-eslint.io/); as typescript-eslint does not
217
252
  support TypeScript 7 yet, these apps use TypeScript 6.
218
253
 
254
+ ### Sass
255
+
256
+ `--sass` adds [Sass](https://sass-lang.com/) to a web app: a `public/stylesheets/style.scss` in place
257
+ of `style.css`, compiled to the CSS the app serves.
258
+
259
+ ```bash
260
+ $ pnpm start # compiles the stylesheets, then starts the app
261
+ $ pnpm dev # compiles them once, then restarts the app on change
262
+ $ pnpm css:watch # recompiles them on change, in a second terminal
263
+ ```
264
+
265
+ Sass is a development dependency, so `pnpm start` fails where only production dependencies are
266
+ installed: compile the stylesheets first with `pnpm css`, as the `--docker` image does.
267
+ [Using Sass](docs/sass.md) covers this, the files it ignores and adding Sass to an existing app.
268
+
219
269
  ## Command Line Options
220
270
 
221
271
  -v, --view <engine> add view <engine> support (ejs|hbs|pug|twig) (defaults to pug)
222
272
  --no-view use static html instead of view engine
223
273
  --api generate a JSON API, without views or static files
274
+ (with --view, a web app with a JSON API at /api)
224
275
  --cjs generate CommonJS modules instead of ES modules
225
276
  --ts generate TypeScript, run directly by Node.js
226
277
  --helmet add helmet middleware for security headers
227
278
  --compression add compression middleware for gzip/brotli responses
228
279
  --cookies add cookie-parser middleware
229
280
  --cors add cors middleware for cross-origin requests
281
+ --cors-scope <scope>
282
+ with --api and --view, the routes other origins can
283
+ call (api|all) (defaults to api)
230
284
  --rate-limit add express-rate-limit to limit requests per client
231
- --session add express-session for sessions (not with --api)
285
+ --session add express-session for sessions (not with --api alone)
232
286
  --csrf add CSRF protection for forms (needs --session)
287
+ --csrf-scope <scope>
288
+ with --api and --view, the routes that need a CSRF
289
+ token (all|pages) (defaults to all)
233
290
  --uploads add multer and an upload route at /uploads
234
291
  --logger <name> request logger (morgan|pino) (defaults to morgan)
292
+ --sass add Sass, compiling .scss stylesheets (not with --api alone)
235
293
  --docker add a Dockerfile for a production image
236
294
  --lint add ESLint and a lint script
237
295
  --pm <name> package manager the app requires (pnpm|npm) (defaults to pnpm)
@@ -248,22 +306,24 @@ support TypeScript 7 yet, these apps use TypeScript 6.
248
306
 
249
307
  This fork started from `express-generator` 4.16.1. It generates Express 5 apps as ES modules, with
250
308
  pug in place of jade, for Node.js 22.9 or newer. `--view=<engine>` replaces the `--ejs`, `--hbs` and
251
- `--pug` aliases, and apps use plain CSS instead of a CSS preprocessor.
309
+ `--pug` aliases, and apps use plain CSS unless you add Sass with `--sass`; the other CSS preprocessors
310
+ are gone.
252
311
 
253
312
  - [Changes from express-generator](docs/changes-from-express-generator.md): everything added,
254
313
  changed and removed, and a table for migrating commands
255
- - [Using Sass](docs/sass.md): adding Sass to a generated app
314
+ - [Using Sass](docs/sass.md): what `--sass` adds, and adding Sass to an existing app
256
315
 
257
316
  ## Contributing
258
317
 
259
318
  ```bash
260
- $ npm install
261
- $ npm test # generates, installs and runs apps for each option
262
- $ npm run lint
319
+ $ pnpm install
320
+ $ pnpm test # generates, installs and runs apps for each option
321
+ $ pnpm lint
263
322
  ```
264
323
 
265
- The tests install the generated apps with pnpm, so [install pnpm](https://pnpm.io/installation)
266
- first. The tests that install an npm app are skipped unless your npm is version 12 or newer.
324
+ This repository requires pnpm 11 or newer, as generated apps do by default, so
325
+ [install pnpm](https://pnpm.io/installation) first; npm refuses to install it or run its scripts.
326
+ The tests that install an npm app are skipped unless your npm is version 12 or newer.
267
327
 
268
328
  See [Maintaining](docs/maintaining.md) for keeping the dependency versions of generated apps current,
269
329
  and for releasing.
@@ -5,7 +5,7 @@ import fs from 'node:fs'
5
5
  import path from 'node:path'
6
6
  import readline from 'node:readline'
7
7
  import util from 'node:util'
8
- import { DEFAULT_PACKAGE_MANAGER, LOGGERS, PACKAGE_MANAGERS, VIEW_ENGINES, checkDestination, createAppName, mergeLines, planApp, scriptCommand } from '../lib/app.js'
8
+ import { CORS_SCOPES, CSRF_SCOPES, DEFAULT_PACKAGE_MANAGER, LOGGERS, PACKAGE_MANAGERS, VIEW_ENGINES, checkDestination, createAppName, mergeLines, planApp, scriptCommand } from '../lib/app.js'
9
9
  import { CancelError } from '../lib/prompts.js'
10
10
  import { wizard } from '../lib/wizard.js'
11
11
 
@@ -19,7 +19,9 @@ const OPTIONS = {
19
19
  compression: { type: 'boolean' },
20
20
  cookies: { type: 'boolean' },
21
21
  cors: { type: 'boolean' },
22
+ 'cors-scope': { type: 'string' },
22
23
  csrf: { type: 'boolean' },
24
+ 'csrf-scope': { type: 'string' },
23
25
  docker: { type: 'boolean' },
24
26
  force: { type: 'boolean', short: 'f' },
25
27
  git: { type: 'boolean' },
@@ -33,6 +35,7 @@ const OPTIONS = {
33
35
  'no-view': { type: 'boolean' },
34
36
  pm: { type: 'string' },
35
37
  'rate-limit': { type: 'boolean' },
38
+ sass: { type: 'boolean' },
36
39
  session: { type: 'boolean' },
37
40
  ts: { type: 'boolean' },
38
41
  uploads: { type: 'boolean' },
@@ -283,6 +286,14 @@ function main (options, done) {
283
286
  usage()
284
287
  error('option `--pm <name>\' argument missing')
285
288
  done(1)
289
+ } else if (options.corsScope === '') {
290
+ usage()
291
+ error('option `--cors-scope <scope>\' argument missing')
292
+ done(1)
293
+ } else if (options.csrfScope === '') {
294
+ usage()
295
+ error('option `--csrf-scope <scope>\' argument missing')
296
+ done(1)
286
297
  } else {
287
298
  // Path
288
299
  const destinationPath = options._[0] || '.'
@@ -290,14 +301,9 @@ function main (options, done) {
290
301
  // App name
291
302
  const appName = createAppName(path.resolve(destinationPath)) || 'hello-world'
292
303
 
293
- // A JSON API has no view engine
294
- if (options.api) {
295
- if (typeof options.view === 'string') {
296
- usage()
297
- error('option `--api\' cannot be used with `--view\'')
298
- return done(1)
299
- }
300
-
304
+ // A JSON API has no view engine, unless one is given with `--view`,
305
+ // for a web app with a JSON API
306
+ if (options.api && typeof options.view !== 'string') {
301
307
  options.view = false
302
308
  }
303
309
 
@@ -340,9 +346,16 @@ function main (options, done) {
340
346
  }
341
347
 
342
348
  // Sessions are for web apps, and CSRF protection needs them and views
343
- if (options.session && options.api) {
349
+ if (options.session && options.api && !options.view) {
344
350
  usage()
345
- error('option `--session\' cannot be used with `--api\'')
351
+ error('option `--session\' cannot be used with `--api\', unless `--view\' is given too')
352
+ return done(1)
353
+ }
354
+
355
+ // Sass is for the stylesheets of web apps
356
+ if (options.sass && options.api && !options.view) {
357
+ usage()
358
+ error('option `--sass\' cannot be used with `--api\', unless `--view\' is given too')
346
359
  return done(1)
347
360
  }
348
361
 
@@ -358,6 +371,38 @@ function main (options, done) {
358
371
  return done(1)
359
372
  }
360
373
 
374
+ // Where CORS and CSRF protection apply is a choice only for a web app
375
+ // with a JSON API
376
+ const webApi = Boolean(options.api && options.view)
377
+
378
+ if (options.corsScope !== undefined) {
379
+ if (!CORS_SCOPES.includes(options.corsScope)) {
380
+ usage()
381
+ error('unsupported CORS scope `' + options.corsScope + "'")
382
+ return done(1)
383
+ }
384
+
385
+ if (!webApi || !options.cors) {
386
+ usage()
387
+ error('option `--cors-scope\' requires `--cors\', `--api\' and `--view\'')
388
+ return done(1)
389
+ }
390
+ }
391
+
392
+ if (options.csrfScope !== undefined) {
393
+ if (!CSRF_SCOPES.includes(options.csrfScope)) {
394
+ usage()
395
+ error('unsupported CSRF scope `' + options.csrfScope + "'")
396
+ return done(1)
397
+ }
398
+
399
+ if (!webApi || !options.csrf) {
400
+ usage()
401
+ error('option `--csrf-scope\' requires `--csrf\', `--api\' and `--view\'')
402
+ return done(1)
403
+ }
404
+ }
405
+
361
406
  // Check the destination before writing anything
362
407
  const entries = planApp(appName, options)
363
408
  const { blockers, conflicts, leftovers } = checkDestination(destinationPath, entries)
@@ -446,6 +491,8 @@ function parseOptions (argv) {
446
491
  options.git = !options['no-git']
447
492
  options.keepConfig = Boolean(options['keep-config'])
448
493
  options.rateLimit = Boolean(options['rate-limit'])
494
+ options.corsScope = options['cors-scope']
495
+ options.csrfScope = options['csrf-scope']
449
496
  options.logger ??= 'morgan'
450
497
  options.pm ??= DEFAULT_PACKAGE_MANAGER
451
498
 
@@ -470,17 +517,25 @@ function usage () {
470
517
  console.log(' -v, --view <engine> add view <engine> support (ejs|hbs|pug|twig) (defaults to pug)')
471
518
  console.log(' --no-view use static html instead of view engine')
472
519
  console.log(' --api generate a JSON API, without views or static files')
520
+ console.log(' (with --view, a web app with a JSON API at /api)')
473
521
  console.log(' --cjs generate CommonJS modules instead of ES modules')
474
522
  console.log(' --ts generate TypeScript, run directly by Node.js')
475
523
  console.log(' --helmet add helmet middleware for security headers')
476
524
  console.log(' --compression add compression middleware for gzip/brotli responses')
477
525
  console.log(' --cookies add cookie-parser middleware')
478
526
  console.log(' --cors add cors middleware for cross-origin requests')
527
+ console.log(' --cors-scope <scope>')
528
+ console.log(' with --api and --view, the routes other origins can')
529
+ console.log(' call (api|all) (defaults to api)')
479
530
  console.log(' --rate-limit add express-rate-limit to limit requests per client')
480
- console.log(' --session add express-session for sessions (not with --api)')
531
+ console.log(' --session add express-session for sessions (not with --api alone)')
481
532
  console.log(' --csrf add CSRF protection for forms (needs --session)')
533
+ console.log(' --csrf-scope <scope>')
534
+ console.log(' with --api and --view, the routes that need a CSRF')
535
+ console.log(' token (all|pages) (defaults to all)')
482
536
  console.log(' --uploads add multer and an upload route at /uploads')
483
537
  console.log(' --logger <name> request logger (morgan|pino) (defaults to morgan)')
538
+ console.log(' --sass add Sass, compiling .scss stylesheets (not with --api alone)')
484
539
  console.log(' --docker add a Dockerfile for a production image')
485
540
  console.log(' --lint add ESLint and a lint script')
486
541
  console.log(' --pm <name> package manager the app requires (pnpm|npm) (defaults to pnpm)')
package/lib/app.js CHANGED
@@ -35,6 +35,11 @@ export const PACKAGE_MANAGERS = {
35
35
 
36
36
  export const DEFAULT_PACKAGE_MANAGER = 'pnpm'
37
37
 
38
+ // where the CORS headers and the CSRF protection of a web app with a JSON
39
+ // API apply, with the default first
40
+ export const CORS_SCOPES = ['api', 'all']
41
+ export const CSRF_SCOPES = ['all', 'pages']
42
+
38
43
  // views generated only with some options
39
44
  const OPTIONAL_VIEWS = { 'new-user': 'csrf', uploads: 'uploads' }
40
45
 
@@ -75,10 +80,11 @@ const TSCONFIG = {
75
80
  // not use: code in the other language, the other ESLint config, the pnpm
76
81
  // settings and the templates of other view engines
77
82
  const ALTERNATIVES = [
78
- ...['app', 'bin/www', 'csrf', 'routes/index', 'routes/uploads', 'routes/users', 'test/app.test'].flatMap((file) => [file + '.js', file + '.ts']),
83
+ ...['app', 'bin/www', 'csrf', 'routes/api', 'routes/index', 'routes/uploads', 'routes/users', 'test/app.test'].flatMap((file) => [file + '.js', file + '.ts']),
79
84
  'eslint.config.js',
80
85
  'eslint.config.mjs',
81
86
  'pnpm-workspace.yaml',
87
+ 'public/stylesheets/style.scss',
82
88
  ...fs.readdirSync(path.join(TEMPLATE_DIR, 'views')).map((file) => 'views/' + file)
83
89
  ]
84
90
 
@@ -107,23 +113,36 @@ export function planApp (name, options) {
107
113
  file(to, fs.readFileSync(path.join(TEMPLATE_DIR, from), 'utf-8'), extra)
108
114
  }
109
115
 
110
- // an ignore file, ignoring uploaded files too
116
+ // an ignore file, ignoring uploaded files and compiled stylesheets too
111
117
  function ignore (from) {
112
- const contents = fs.readFileSync(path.join(TEMPLATE_DIR, from), 'utf-8')
113
- return options.uploads ? contents + '\n# Uploaded files\nuploads/\n' : contents
118
+ let contents = fs.readFileSync(path.join(TEMPLATE_DIR, from), 'utf-8')
119
+ if (options.uploads) contents += '\n# Uploaded files\nuploads/\n'
120
+ if (sass) contents += '\n# Stylesheets compiled from Sass: write styles in .scss files\npublic/stylesheets/*.css\npublic/stylesheets/*.css.map\n'
121
+ return contents
114
122
  }
115
123
 
116
124
  // Module format
117
125
  const esm = !options.cjs
118
126
  const dirname = esm ? 'import.meta.dirname' : '__dirname'
119
127
 
120
- // App kind: a JSON API has no views or static files
121
- const api = Boolean(options.api)
128
+ // App kind: a JSON API has no views or static files, and a web app with
129
+ // a view engine can have a JSON API too, under /api
130
+ const api = Boolean(options.api) && !options.view
131
+ const webApi = Boolean(options.api) && Boolean(options.view)
122
132
 
123
133
  // Sessions, and CSRF protection using them, are for web apps with views
124
134
  const session = Boolean(options.session) && !api
125
135
  const csrf = Boolean(options.csrf) && session && Boolean(options.view)
126
136
 
137
+ // In a web app with a JSON API, other origins can call only the API,
138
+ // unless the whole app is chosen, and CSRF protection covers the API,
139
+ // unless only the pages are chosen
140
+ const corsApi = webApi && options.corsScope !== 'all'
141
+ const csrfSkipApi = webApi && csrf && options.csrfScope === 'pages'
142
+
143
+ // Sass, for the stylesheets of apps with static files
144
+ const sass = Boolean(options.sass) && !api
145
+
127
146
  // File uploads, with an upload form in apps with views
128
147
  const uploads = Boolean(options.uploads)
129
148
 
@@ -182,6 +201,7 @@ export function planApp (name, options) {
182
201
  }
183
202
 
184
203
  app.locals.dirname = dirname
204
+ app.locals.webApi = webApi
185
205
  app.locals.httpErrors = Boolean(options.view) || api
186
206
  app.locals.trustProxy = trustProxy
187
207
  app.locals.session = session
@@ -191,6 +211,8 @@ export function planApp (name, options) {
191
211
  test.locals.csrf = csrf
192
212
  test.locals.uploads = uploads
193
213
  test.locals.view = Boolean(options.view)
214
+ test.locals.webApi = webApi
215
+ test.locals.csrfSkipApi = csrfSkipApi
194
216
 
195
217
  // App modules
196
218
  app.locals.localModules = Object.create(null)
@@ -216,7 +238,7 @@ export function planApp (name, options) {
216
238
  // Cross-origin requests
217
239
  if (options.cors) {
218
240
  app.locals.modules.cors = 'cors'
219
- app.locals.uses.push("cors({\n // any origin, or only the comma-separated origins in CORS_ORIGIN\n origin: process.env.CORS_ORIGIN?.split(',').map((origin) => origin.trim()) ?? '*'\n})")
241
+ app.locals.uses.push((corsApi ? "'/api', " : '') + "cors({\n // any origin, or only the comma-separated origins in CORS_ORIGIN\n origin: process.env.CORS_ORIGIN?.split(',').map((origin) => origin.trim()) ?? '*'\n})")
220
242
  pkg.dependencies.cors = VERSIONS.cors
221
243
  }
222
244
 
@@ -258,7 +280,25 @@ export function planApp (name, options) {
258
280
  dir('public/stylesheets')
259
281
 
260
282
  // Stylesheet
261
- copy('public/stylesheets/style.css', 'public/stylesheets/style.css')
283
+ if (sass) {
284
+ copy('public/stylesheets/style.scss', 'public/stylesheets/style.scss')
285
+ } else {
286
+ copy('public/stylesheets/style.css', 'public/stylesheets/style.css')
287
+ }
288
+ }
289
+
290
+ // Sass compiles each .scss file to the .css file the app serves, before
291
+ // the app starts: a development dependency, so the stylesheets of an app
292
+ // installed without them are compiled first, as in the Dockerfile
293
+ if (sass) {
294
+ const styles = 'public/stylesheets:public/stylesheets'
295
+
296
+ pkg.scripts.css = 'sass ' + styles
297
+ // recompile on change, alongside the dev script
298
+ pkg.scripts['css:watch'] = 'sass --watch ' + styles
299
+ pkg.scripts.prestart = scriptCommand(pm, 'css')
300
+ pkg.scripts.predev = scriptCommand(pm, 'css')
301
+ pkg.devDependencies.sass = VERSIONS.sass
262
302
  }
263
303
 
264
304
  // copy route templates
@@ -304,6 +344,16 @@ export function planApp (name, options) {
304
344
  pkg.dependencies['http-errors'] = VERSIONS['http-errors']
305
345
  }
306
346
 
347
+ // API router, for the JSON routes of a web app
348
+ if (webApi) {
349
+ const router = loadTemplate('app/routes/api.js')
350
+ router.locals.esm = esm
351
+ file('routes/api.' + ext, router.render())
352
+
353
+ app.locals.localModules.apiRouter = './routes/api.' + ext
354
+ app.locals.mounts.push({ path: '/api', code: 'apiRouter' })
355
+ }
356
+
307
357
  // Template support
308
358
  if (options.view) {
309
359
  const view = VIEW_ENGINES[options.view]
@@ -373,6 +423,7 @@ export function planApp (name, options) {
373
423
  const module = loadTemplate('app/csrf.js')
374
424
  module.locals.esm = esm
375
425
  module.locals.uploads = uploads
426
+ module.locals.skipApi = csrfSkipApi
376
427
  file('csrf.' + ext, module.render())
377
428
 
378
429
  app.locals.localModules.csrf = './csrf.' + ext
@@ -394,7 +445,15 @@ export function planApp (name, options) {
394
445
 
395
446
  // Package manager settings, protecting installs from compromised packages
396
447
  if (pm === 'pnpm') {
397
- copy('app/pnpm-workspace.yaml', 'pnpm-workspace.yaml', { config: true })
448
+ let settings = fs.readFileSync(path.join(TEMPLATE_DIR, 'app/pnpm-workspace.yaml'), 'utf-8')
449
+
450
+ // Sass's optional file watcher has an install script, which would stop
451
+ // the install, and which it works without
452
+ if (sass) {
453
+ settings += '\n# Do not run the install script of the file watcher Sass uses, which works\n# without it.\nallowBuilds:\n \'@parcel/watcher\': false\n'
454
+ }
455
+
456
+ file('pnpm-workspace.yaml', settings, { config: true })
398
457
  } else {
399
458
  copy('app/npmrc', '.npmrc', { config: true })
400
459
  }
@@ -405,6 +464,8 @@ export function planApp (name, options) {
405
464
  dockerfile.locals.name = name
406
465
  dockerfile.locals.www = www
407
466
  dockerfile.locals.uploads = uploads
467
+ dockerfile.locals.sass = sass
468
+ dockerfile.locals.css = scriptCommand(pm, 'css')
408
469
  dockerfile.locals.pm = pm
409
470
  // the exact package manager version, for repeatable image builds
410
471
  dockerfile.locals.pmVersion = VERSIONS[pm].replace(/^\^/, '')
@@ -550,8 +611,7 @@ export function scriptCommand (pm, script) {
550
611
 
551
612
  export function mergeLines (existing, generated) {
552
613
  const have = new Set(existing.split(/\r?\n/).map((line) => line.trim()))
553
- const missing = generated.split('\n')
554
- .map((line) => line.trim())
614
+ const missing = [...new Set(generated.split('\n').map((line) => line.trim()))]
555
615
  .filter((line) => line && !line.startsWith('#') && !have.has(line))
556
616
 
557
617
  if (!missing.length) return existing
package/lib/wizard.js CHANGED
@@ -47,14 +47,20 @@ export async function wizard (io) {
47
47
  initial: answers.kind,
48
48
  choices: [
49
49
  { label: 'Web app', value: 'web', hint: 'server-rendered views' },
50
- { label: 'JSON API', value: 'api', hint: 'no views or static files' }
50
+ { label: 'JSON API', value: 'api', hint: 'no views or static files' },
51
+ { label: 'Web app with a JSON API', value: 'both', hint: 'under /api' }
51
52
  ]
52
53
  })
53
54
  },
54
55
  {
55
56
  name: 'view',
56
- when: () => answers.kind === 'web',
57
- ask: (step) => step.ask(select, { message: 'View engine', initial: answers.view, choices: VIEWS })
57
+ when: () => answers.kind !== 'api',
58
+ ask: (step) => step.ask(select, {
59
+ message: 'View engine',
60
+ initial: answers.view,
61
+ // the pages of a web app with a JSON API need a view engine
62
+ choices: answers.kind === 'both' ? VIEWS.filter((view) => view.value !== false) : VIEWS
63
+ })
58
64
  },
59
65
  {
60
66
  name: 'language',
@@ -80,16 +86,40 @@ export async function wizard (io) {
80
86
  { label: 'cors', value: 'cors', hint: 'cross-origin requests' },
81
87
  { label: 'express-rate-limit', value: 'rate-limit', hint: 'limit requests per client' },
82
88
  // sessions are for web apps
83
- ...(answers.kind === 'web' ? [{ label: 'express-session', value: 'session', hint: 'req.session' }] : []),
89
+ ...(answers.kind !== 'api' ? [{ label: 'express-session', value: 'session', hint: 'req.session' }] : []),
84
90
  { label: 'multer', value: 'uploads', hint: 'file uploads at /uploads' }
85
91
  ]
86
92
  })
87
93
  },
94
+ {
95
+ name: 'corsScope',
96
+ when: () => answers.kind === 'both' && answers.middleware.includes('cors'),
97
+ ask: (step) => step.ask(select, {
98
+ message: 'Other origins can call',
99
+ initial: answers.corsScope,
100
+ choices: [
101
+ { label: 'The API only', value: 'api', hint: 'routes under /api' },
102
+ { label: 'The whole app', value: 'all', hint: 'the pages too' }
103
+ ]
104
+ })
105
+ },
88
106
  {
89
107
  name: 'csrf',
90
- when: () => answers.kind === 'web' && answers.view !== false && answers.middleware.includes('session'),
108
+ when: () => answers.kind !== 'api' && answers.view !== false && answers.middleware.includes('session'),
91
109
  ask: (step) => step.ask(confirm, { message: 'Protect forms with CSRF tokens?', initial: answers.csrf ?? true })
92
110
  },
111
+ {
112
+ name: 'csrfScope',
113
+ when: () => answers.kind === 'both' && answers.middleware.includes('session') && answers.csrf === true,
114
+ ask: (step) => step.ask(select, {
115
+ message: 'CSRF tokens for the API',
116
+ initial: answers.csrfScope,
117
+ choices: [
118
+ { label: 'Required', value: 'all', hint: 'your pages call the API' },
119
+ { label: 'Not required', value: 'pages', hint: 'other clients call it, without the session cookie' }
120
+ ]
121
+ })
122
+ },
93
123
  {
94
124
  name: 'logger',
95
125
  ask: (step) => step.ask(select, {
@@ -108,7 +138,9 @@ export async function wizard (io) {
108
138
  initial: answers.extras,
109
139
  choices: [
110
140
  { label: 'Dockerfile', value: 'docker', hint: 'production image with a /health check' },
111
- { label: 'ESLint', value: 'lint', hint: 'with a lint script' }
141
+ { label: 'ESLint', value: 'lint', hint: 'with a lint script' },
142
+ // stylesheets are for web apps
143
+ ...(answers.kind !== 'api' ? [{ label: 'Sass', value: 'sass', hint: 'stylesheets compiled from .scss files' }] : [])
112
144
  ]
113
145
  })
114
146
  },
@@ -305,18 +337,22 @@ function eraseText (io, printed) {
305
337
 
306
338
  function toOptions (answers) {
307
339
  const { directory, kind, language, middleware = [], extras = [] } = answers
308
- const view = kind === 'web' ? answers.view : false
309
- const session = kind === 'web' && middleware.includes('session')
340
+ const view = kind === 'api' ? false : answers.view
341
+ const session = kind !== 'api' && middleware.includes('session')
342
+ const csrf = session && view !== false && answers.csrf === true
310
343
 
311
344
  return {
312
345
  _: [directory.dir],
313
346
  '!': [],
314
- api: kind === 'api',
347
+ api: kind !== 'web',
315
348
  cjs: language === 'cjs',
316
349
  compression: middleware.includes('compression'),
317
350
  cookies: middleware.includes('cookies'),
318
351
  cors: middleware.includes('cors'),
319
- csrf: session && view !== false && answers.csrf === true,
352
+ // the choices of a web app with a JSON API
353
+ ...(kind === 'both' && middleware.includes('cors') && { corsScope: answers.corsScope ?? 'api' }),
354
+ csrf,
355
+ ...(kind === 'both' && csrf && { csrfScope: answers.csrfScope ?? 'all' }),
320
356
  docker: extras.includes('docker'),
321
357
  force: directory.force,
322
358
  git: answers.git,
@@ -327,6 +363,7 @@ function toOptions (answers) {
327
363
  logger: answers.logger ?? 'morgan',
328
364
  pm: answers.pm ?? DEFAULT_PACKAGE_MANAGER,
329
365
  rateLimit: middleware.includes('rate-limit'),
366
+ sass: kind !== 'api' && extras.includes('sass'),
330
367
  session,
331
368
  ts: language === 'ts',
332
369
  uploads: middleware.includes('uploads'),
@@ -345,7 +382,8 @@ export function toCommand (options) {
345
382
  const args = [pm, 'create', 'express-new@latest', quote(options._[0])]
346
383
  const flags = []
347
384
 
348
- if (options.api) flags.push('--api')
385
+ // a JSON API has no view engine, unless one is given, the default included
386
+ if (options.api) flags.push('--api', ...(options.view ? ['--view=' + options.view] : []))
349
387
  else if (options.view === false) flags.push('--no-view')
350
388
  else if (options.view !== 'pug') flags.push('--view=' + options.view)
351
389
 
@@ -355,11 +393,14 @@ export function toCommand (options) {
355
393
  if (options.compression) flags.push('--compression')
356
394
  if (options.cookies) flags.push('--cookies')
357
395
  if (options.cors) flags.push('--cors')
396
+ if (options.corsScope === 'all') flags.push('--cors-scope=all')
358
397
  if (options.rateLimit) flags.push('--rate-limit')
359
398
  if (options.session) flags.push('--session')
360
399
  if (options.csrf) flags.push('--csrf')
400
+ if (options.csrfScope === 'pages') flags.push('--csrf-scope=pages')
361
401
  if (options.uploads) flags.push('--uploads')
362
402
  if (options.logger && options.logger !== 'morgan') flags.push('--logger=' + options.logger)
403
+ if (options.sass) flags.push('--sass')
363
404
  if (options.docker) flags.push('--docker')
364
405
  if (options.lint) flags.push('--lint')
365
406
  if (pm !== DEFAULT_PACKAGE_MANAGER) flags.push('--pm=' + pm)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-express-new",
3
3
  "description": "Express 5 application generator: ESM, TypeScript and CommonJS app templates. A modern successor to express-generator.",
4
- "version": "1.1.0",
4
+ "version": "1.3.0",
5
5
  "author": "Michael Caines (https://github.com/w0244079)",
6
6
  "contributors": [
7
7
  "TJ Holowaychuk <tj@vision-media.ca>",
@@ -27,7 +27,10 @@
27
27
  "web",
28
28
  "api"
29
29
  ],
30
- "repository": "github:w0244079/create-express-new",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "github:w0244079/create-express-new"
33
+ },
31
34
  "homepage": "https://github.com/w0244079/create-express-new#readme",
32
35
  "bugs": "https://github.com/w0244079/create-express-new/issues",
33
36
  "license": "MIT",
@@ -41,6 +44,7 @@
41
44
  },
42
45
  "devDependencies": {
43
46
  "eslint": "^9.39.5",
47
+ "fast-check": "^4.10.2",
44
48
  "mocha": "^12.0.3",
45
49
  "neostandard": "^0.13.0",
46
50
  "supertest": "^7.3.0",
@@ -50,6 +54,13 @@
50
54
  "engines": {
51
55
  "node": ">=22.9"
52
56
  },
57
+ "devEngines": {
58
+ "packageManager": {
59
+ "name": "pnpm",
60
+ "version": ">=11",
61
+ "onFail": "error"
62
+ }
63
+ },
53
64
  "files": [
54
65
  "CHANGELOG.md",
55
66
  "LICENSE",
@@ -61,7 +72,6 @@
61
72
  "lint": "eslint .",
62
73
  "test": "mocha --reporter spec --bail --check-leaks test/",
63
74
  "test-ci": "mocha --reporter spec --check-leaks test/",
64
- "versions": "node scripts/versions.js",
65
- "prepublishOnly": "npm run lint && npm run test-ci"
75
+ "versions": "node scripts/versions.js"
66
76
  }
67
- }
77
+ }
@@ -36,6 +36,20 @@ COPY package*.json .npmrc ./
36
36
  RUN npm ci --omit=dev
37
37
  <% } -%>
38
38
 
39
+ <% if (sass) { -%>
40
+ # Stylesheets are compiled in their own stage too, as Sass is a development
41
+ # dependency, which the final image does not have.
42
+ FROM dependencies AS stylesheets
43
+
44
+ <% if (pm === 'pnpm') { -%>
45
+ RUN pnpm install --frozen-lockfile
46
+ <% } else { -%>
47
+ RUN npm ci
48
+ <% } -%>
49
+ COPY public/stylesheets ./public/stylesheets
50
+ RUN <%- css %>
51
+
52
+ <% } -%>
39
53
  FROM node:22-slim
40
54
 
41
55
  ENV NODE_ENV=production
@@ -47,6 +61,11 @@ COPY --from=dependencies /app/node_modules ./node_modules
47
61
  # copy the app owned by the unprivileged node user, which runs it, so it can
48
62
  # read the files whatever their permissions on the build machine
49
63
  COPY --chown=node:node . .
64
+ <% if (sass) { -%>
65
+
66
+ # the stylesheets compiled above
67
+ COPY --chown=node:node --from=stylesheets /app/public/stylesheets ./public/stylesheets
68
+ <% } -%>
50
69
  <% if (uploads) { -%>
51
70
 
52
71
  # a folder for uploaded files that the node user can write to; files saved
@@ -84,7 +84,31 @@ app.use((req, res, next) => {
84
84
  next(createError(404));
85
85
  });
86
86
 
87
+ <% if (webApi) { -%>
88
+ // error handler for the API, responding with JSON to every error of a
89
+ // request under /api, including those of the middleware above
90
+ <% if (ts) { -%>
91
+ app.use('/api', (err: HttpError, req: Request, res: Response, next: NextFunction) => {
92
+ <% } else { -%>
93
+ app.use('/api', (err, req, res, next) => {
94
+ <% } -%>
95
+ const status = err.status || 500;
96
+ const development = req.app.get('env') === 'development';
97
+
98
+ // log server errors, which are bugs, but not client errors
99
+ if (status >= 500) console.error(err);
100
+
101
+ // only expose client error messages, or every error in development
102
+ res.status(status).json({
103
+ error: err.expose || development ? err.message : 'Internal Server Error',
104
+ ...(development && { stack: err.stack })
105
+ });
106
+ });
107
+
108
+ // error handler for the pages
109
+ <% } else { -%>
87
110
  // error handler
111
+ <% } -%>
88
112
  <% if (ts) { -%>
89
113
  app.use((err: HttpError, req: Request, res: Response, next: NextFunction) => {
90
114
  <% } else { -%>
@@ -7,8 +7,17 @@ const { csrfSync } = require('csrf-sync');
7
7
  // CSRF protection for requests that change data, which must send the token
8
8
  // in a _csrf form field or an x-csrf-token header (GET is not checked)
9
9
  const csrf = csrfSync({
10
- getTokenFromRequest: (req) => req.body?._csrf ?? req.get('x-csrf-token')<%- uploads ? ',' : '' %>
11
- <% if (uploads) { -%>
10
+ getTokenFromRequest: (req) => req.body?._csrf ?? req.get('x-csrf-token')<%- uploads || skipApi ? ',' : '' %>
11
+ <% if (uploads && skipApi) { -%>
12
+ // uploads check the token themselves, as multer reads their form, and the
13
+ // API is not protected, so its routes must not trust the session cookie:
14
+ // authenticate them another way, such as a token in a header
15
+ skipCsrfProtection: (req) => req.path === '/uploads' || /^\/api(\/|$)/i.test(req.path)
16
+ <% } else if (skipApi) { -%>
17
+ // the API is not protected, so its routes must not trust the session
18
+ // cookie: authenticate them another way, such as a token in a header
19
+ skipCsrfProtection: (req) => /^\/api(\/|$)/i.test(req.path)
20
+ <% } else if (uploads) { -%>
12
21
  // uploads check the token themselves, as multer reads their form
13
22
  skipCsrfProtection: (req) => req.path === '/uploads'
14
23
  <% } -%>
@@ -0,0 +1,26 @@
1
+ <% if (esm) { -%>
2
+ import express from 'express';
3
+ <% } else { -%>
4
+ const express = require('express');
5
+ <% } -%>
6
+
7
+ const router = express.Router();
8
+
9
+ // JSON routes, mounted at /api: errors under /api, such as the 404 of a
10
+ // route that is not here, respond with JSON too (see the app's error handlers)
11
+
12
+ /* GET the API's welcome message. */
13
+ router.get('/', (req, res) => {
14
+ res.json({ message: 'Welcome to Express' });
15
+ });
16
+
17
+ /* GET users listing. */
18
+ router.get('/users', (req, res) => {
19
+ res.json([]);
20
+ });
21
+
22
+ <% if (esm) { -%>
23
+ export default router;
24
+ <% } else { -%>
25
+ module.exports = router;
26
+ <% } -%>
@@ -194,6 +194,54 @@ describe('app', () => {
194
194
  it('responds with 404 for unknown routes', async () => {
195
195
  const res = await fetch(`${baseUrl}/does-not-exist`);
196
196
  assert.strictEqual(res.status, 404);
197
+ <% if (webApi) { -%>
198
+ assert.match(res.headers.get('content-type') ?? '', /text\/html/);
199
+ <% } -%>
200
+ });
201
+ <% if (webApi) { -%>
202
+
203
+ it('responds to GET /api', async () => {
204
+ const res = await fetch(`${baseUrl}/api`);
205
+ assert.strictEqual(res.status, 200);
206
+ assert.deepStrictEqual(await res.json(), { message: 'Welcome to Express' });
197
207
  });
208
+
209
+ it('responds to GET /api/users', async () => {
210
+ const res = await fetch(`${baseUrl}/api/users`);
211
+ assert.strictEqual(res.status, 200);
212
+ assert.deepStrictEqual(await res.json(), []);
213
+ });
214
+
215
+ it('responds with a JSON 404 for unknown API routes', async () => {
216
+ const res = await fetch(`${baseUrl}/api/does-not-exist`);
217
+ assert.strictEqual(res.status, 404);
218
+ assert.match(res.headers.get('content-type') ?? '', /application\/json/);
219
+ assert.strictEqual((await res.json()).error, 'Not Found');
220
+ });
221
+
222
+ it('responds with a JSON error for an invalid API request body', async () => {
223
+ const res = await fetch(`${baseUrl}/api/users`, {
224
+ method: 'POST',
225
+ headers: { 'content-type': 'application/json' },
226
+ body: '{'
227
+ });
228
+ assert.strictEqual(res.status, 400);
229
+ assert.match(res.headers.get('content-type') ?? '', /application\/json/);
230
+ });
231
+ <% if (csrf && csrfSkipApi) { -%>
232
+
233
+ it('does not ask API posts for a CSRF token', async () => {
234
+ const res = await fetch(`${baseUrl}/api/does-not-exist`, { method: 'POST' });
235
+ assert.strictEqual(res.status, 404);
236
+ });
237
+ <% } else if (csrf) { -%>
238
+
239
+ it('rejects API posts without a CSRF token, with a JSON error', async () => {
240
+ const res = await fetch(`${baseUrl}/api/users`, { method: 'POST' });
241
+ assert.strictEqual(res.status, 403);
242
+ assert.match(res.headers.get('content-type') ?? '', /application\/json/);
243
+ });
244
+ <% } -%>
245
+ <% } -%>
198
246
  });
199
247
  <% } -%>
@@ -0,0 +1,10 @@
1
+ $link-color: #00B7FF;
2
+
3
+ body {
4
+ padding: 50px;
5
+ font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
6
+ }
7
+
8
+ a {
9
+ color: $link-color;
10
+ }
@@ -30,6 +30,7 @@
30
30
  "pino-pretty": "^13.1.3",
31
31
  "pnpm": "^12.9.1",
32
32
  "pug": "^3.0.4",
33
+ "sass": "^1.105.1",
33
34
  "twig": "^3.0.0",
34
35
  "typescript": "^7.0.2",
35
36
  "typescript-eslint": "^8.71.0",