create-express-new 1.1.0 → 1.2.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,21 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.0] - 2026-10-11
10
+
11
+ ### Added
12
+
13
+ - `--api` with a view engine, such as `--api --view=pug`, generates a web app with a JSON API: a
14
+ `routes/api.js` router at `/api`, and JSON responses for every error under `/api`. The wizard offers
15
+ it as "Web app with a JSON API"
16
+ - `--cors-scope` and `--csrf-scope`, for a web app with a JSON API, choose whether other origins can
17
+ call only the API (default) or the whole app, and whether the API needs the CSRF token (default) or
18
+ only the pages do; the wizard asks both
19
+
20
+ ### Changed
21
+
22
+ - `--api` with `--view` is no longer an error, and `--session` and `--csrf` can be used with them
23
+
9
24
  ## [1.1.0] - 2026-10-10
10
25
 
11
26
  ### Added
@@ -121,6 +136,7 @@ the full list and a table for migrating commands.
121
136
  - The `-e/--ejs`, `--hbs`, `--pug` and `-H/--hogan` aliases; use `--view=<engine>`
122
137
  - The `debug` package and `DEBUG=...` start instructions
123
138
 
124
- [Unreleased]: https://github.com/w0244079/create-express-new/compare/v1.1.0...HEAD
139
+ [Unreleased]: https://github.com/w0244079/create-express-new/compare/v1.2.0...HEAD
140
+ [1.2.0]: https://github.com/w0244079/create-express-new/compare/v1.1.0...v1.2.0
125
141
  [1.1.0]: https://github.com/w0244079/create-express-new/compare/v1.0.0...v1.1.0
126
142
  [1.0.0]: https://github.com/w0244079/create-express-new/releases/tag/v1.0.0
package/README.md CHANGED
@@ -26,7 +26,7 @@ $ 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
@@ -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
@@ -221,15 +255,22 @@ support TypeScript 7 yet, these apps use TypeScript 6.
221
255
  -v, --view <engine> add view <engine> support (ejs|hbs|pug|twig) (defaults to pug)
222
256
  --no-view use static html instead of view engine
223
257
  --api generate a JSON API, without views or static files
258
+ (with --view, a web app with a JSON API at /api)
224
259
  --cjs generate CommonJS modules instead of ES modules
225
260
  --ts generate TypeScript, run directly by Node.js
226
261
  --helmet add helmet middleware for security headers
227
262
  --compression add compression middleware for gzip/brotli responses
228
263
  --cookies add cookie-parser middleware
229
264
  --cors add cors middleware for cross-origin requests
265
+ --cors-scope <scope>
266
+ with --api and --view, the routes other origins can
267
+ call (api|all) (defaults to api)
230
268
  --rate-limit add express-rate-limit to limit requests per client
231
- --session add express-session for sessions (not with --api)
269
+ --session add express-session for sessions (not with --api alone)
232
270
  --csrf add CSRF protection for forms (needs --session)
271
+ --csrf-scope <scope>
272
+ with --api and --view, the routes that need a CSRF
273
+ token (all|pages) (defaults to all)
233
274
  --uploads add multer and an upload route at /uploads
234
275
  --logger <name> request logger (morgan|pino) (defaults to morgan)
235
276
  --docker add a Dockerfile for a production image
@@ -257,13 +298,14 @@ pug in place of jade, for Node.js 22.9 or newer. `--view=<engine>` replaces the
257
298
  ## Contributing
258
299
 
259
300
  ```bash
260
- $ npm install
261
- $ npm test # generates, installs and runs apps for each option
262
- $ npm run lint
301
+ $ pnpm install
302
+ $ pnpm test # generates, installs and runs apps for each option
303
+ $ pnpm lint
263
304
  ```
264
305
 
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.
306
+ This repository requires pnpm 11 or newer, as generated apps do by default, so
307
+ [install pnpm](https://pnpm.io/installation) first; npm refuses to install it or run its scripts.
308
+ The tests that install an npm app are skipped unless your npm is version 12 or newer.
267
309
 
268
310
  See [Maintaining](docs/maintaining.md) for keeping the dependency versions of generated apps current,
269
311
  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' },
@@ -283,6 +285,14 @@ function main (options, done) {
283
285
  usage()
284
286
  error('option `--pm <name>\' argument missing')
285
287
  done(1)
288
+ } else if (options.corsScope === '') {
289
+ usage()
290
+ error('option `--cors-scope <scope>\' argument missing')
291
+ done(1)
292
+ } else if (options.csrfScope === '') {
293
+ usage()
294
+ error('option `--csrf-scope <scope>\' argument missing')
295
+ done(1)
286
296
  } else {
287
297
  // Path
288
298
  const destinationPath = options._[0] || '.'
@@ -290,14 +300,9 @@ function main (options, done) {
290
300
  // App name
291
301
  const appName = createAppName(path.resolve(destinationPath)) || 'hello-world'
292
302
 
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
-
303
+ // A JSON API has no view engine, unless one is given with `--view`,
304
+ // for a web app with a JSON API
305
+ if (options.api && typeof options.view !== 'string') {
301
306
  options.view = false
302
307
  }
303
308
 
@@ -340,9 +345,9 @@ function main (options, done) {
340
345
  }
341
346
 
342
347
  // Sessions are for web apps, and CSRF protection needs them and views
343
- if (options.session && options.api) {
348
+ if (options.session && options.api && !options.view) {
344
349
  usage()
345
- error('option `--session\' cannot be used with `--api\'')
350
+ error('option `--session\' cannot be used with `--api\', unless `--view\' is given too')
346
351
  return done(1)
347
352
  }
348
353
 
@@ -358,6 +363,38 @@ function main (options, done) {
358
363
  return done(1)
359
364
  }
360
365
 
366
+ // Where CORS and CSRF protection apply is a choice only for a web app
367
+ // with a JSON API
368
+ const webApi = Boolean(options.api && options.view)
369
+
370
+ if (options.corsScope !== undefined) {
371
+ if (!CORS_SCOPES.includes(options.corsScope)) {
372
+ usage()
373
+ error('unsupported CORS scope `' + options.corsScope + "'")
374
+ return done(1)
375
+ }
376
+
377
+ if (!webApi || !options.cors) {
378
+ usage()
379
+ error('option `--cors-scope\' requires `--cors\', `--api\' and `--view\'')
380
+ return done(1)
381
+ }
382
+ }
383
+
384
+ if (options.csrfScope !== undefined) {
385
+ if (!CSRF_SCOPES.includes(options.csrfScope)) {
386
+ usage()
387
+ error('unsupported CSRF scope `' + options.csrfScope + "'")
388
+ return done(1)
389
+ }
390
+
391
+ if (!webApi || !options.csrf) {
392
+ usage()
393
+ error('option `--csrf-scope\' requires `--csrf\', `--api\' and `--view\'')
394
+ return done(1)
395
+ }
396
+ }
397
+
361
398
  // Check the destination before writing anything
362
399
  const entries = planApp(appName, options)
363
400
  const { blockers, conflicts, leftovers } = checkDestination(destinationPath, entries)
@@ -446,6 +483,8 @@ function parseOptions (argv) {
446
483
  options.git = !options['no-git']
447
484
  options.keepConfig = Boolean(options['keep-config'])
448
485
  options.rateLimit = Boolean(options['rate-limit'])
486
+ options.corsScope = options['cors-scope']
487
+ options.csrfScope = options['csrf-scope']
449
488
  options.logger ??= 'morgan'
450
489
  options.pm ??= DEFAULT_PACKAGE_MANAGER
451
490
 
@@ -470,15 +509,22 @@ function usage () {
470
509
  console.log(' -v, --view <engine> add view <engine> support (ejs|hbs|pug|twig) (defaults to pug)')
471
510
  console.log(' --no-view use static html instead of view engine')
472
511
  console.log(' --api generate a JSON API, without views or static files')
512
+ console.log(' (with --view, a web app with a JSON API at /api)')
473
513
  console.log(' --cjs generate CommonJS modules instead of ES modules')
474
514
  console.log(' --ts generate TypeScript, run directly by Node.js')
475
515
  console.log(' --helmet add helmet middleware for security headers')
476
516
  console.log(' --compression add compression middleware for gzip/brotli responses')
477
517
  console.log(' --cookies add cookie-parser middleware')
478
518
  console.log(' --cors add cors middleware for cross-origin requests')
519
+ console.log(' --cors-scope <scope>')
520
+ console.log(' with --api and --view, the routes other origins can')
521
+ console.log(' call (api|all) (defaults to api)')
479
522
  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)')
523
+ console.log(' --session add express-session for sessions (not with --api alone)')
481
524
  console.log(' --csrf add CSRF protection for forms (needs --session)')
525
+ console.log(' --csrf-scope <scope>')
526
+ console.log(' with --api and --view, the routes that need a CSRF')
527
+ console.log(' token (all|pages) (defaults to all)')
482
528
  console.log(' --uploads add multer and an upload route at /uploads')
483
529
  console.log(' --logger <name> request logger (morgan|pino) (defaults to morgan)')
484
530
  console.log(' --docker add a Dockerfile for a production image')
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,7 +80,7 @@ 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',
@@ -117,13 +122,21 @@ export function planApp (name, options) {
117
122
  const esm = !options.cjs
118
123
  const dirname = esm ? 'import.meta.dirname' : '__dirname'
119
124
 
120
- // App kind: a JSON API has no views or static files
121
- const api = Boolean(options.api)
125
+ // App kind: a JSON API has no views or static files, and a web app with
126
+ // a view engine can have a JSON API too, under /api
127
+ const api = Boolean(options.api) && !options.view
128
+ const webApi = Boolean(options.api) && Boolean(options.view)
122
129
 
123
130
  // Sessions, and CSRF protection using them, are for web apps with views
124
131
  const session = Boolean(options.session) && !api
125
132
  const csrf = Boolean(options.csrf) && session && Boolean(options.view)
126
133
 
134
+ // In a web app with a JSON API, other origins can call only the API,
135
+ // unless the whole app is chosen, and CSRF protection covers the API,
136
+ // unless only the pages are chosen
137
+ const corsApi = webApi && options.corsScope !== 'all'
138
+ const csrfSkipApi = webApi && csrf && options.csrfScope === 'pages'
139
+
127
140
  // File uploads, with an upload form in apps with views
128
141
  const uploads = Boolean(options.uploads)
129
142
 
@@ -182,6 +195,7 @@ export function planApp (name, options) {
182
195
  }
183
196
 
184
197
  app.locals.dirname = dirname
198
+ app.locals.webApi = webApi
185
199
  app.locals.httpErrors = Boolean(options.view) || api
186
200
  app.locals.trustProxy = trustProxy
187
201
  app.locals.session = session
@@ -191,6 +205,8 @@ export function planApp (name, options) {
191
205
  test.locals.csrf = csrf
192
206
  test.locals.uploads = uploads
193
207
  test.locals.view = Boolean(options.view)
208
+ test.locals.webApi = webApi
209
+ test.locals.csrfSkipApi = csrfSkipApi
194
210
 
195
211
  // App modules
196
212
  app.locals.localModules = Object.create(null)
@@ -216,7 +232,7 @@ export function planApp (name, options) {
216
232
  // Cross-origin requests
217
233
  if (options.cors) {
218
234
  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})")
235
+ 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
236
  pkg.dependencies.cors = VERSIONS.cors
221
237
  }
222
238
 
@@ -304,6 +320,16 @@ export function planApp (name, options) {
304
320
  pkg.dependencies['http-errors'] = VERSIONS['http-errors']
305
321
  }
306
322
 
323
+ // API router, for the JSON routes of a web app
324
+ if (webApi) {
325
+ const router = loadTemplate('app/routes/api.js')
326
+ router.locals.esm = esm
327
+ file('routes/api.' + ext, router.render())
328
+
329
+ app.locals.localModules.apiRouter = './routes/api.' + ext
330
+ app.locals.mounts.push({ path: '/api', code: 'apiRouter' })
331
+ }
332
+
307
333
  // Template support
308
334
  if (options.view) {
309
335
  const view = VIEW_ENGINES[options.view]
@@ -373,6 +399,7 @@ export function planApp (name, options) {
373
399
  const module = loadTemplate('app/csrf.js')
374
400
  module.locals.esm = esm
375
401
  module.locals.uploads = uploads
402
+ module.locals.skipApi = csrfSkipApi
376
403
  file('csrf.' + ext, module.render())
377
404
 
378
405
  app.locals.localModules.csrf = './csrf.' + ext
@@ -550,8 +577,7 @@ export function scriptCommand (pm, script) {
550
577
 
551
578
  export function mergeLines (existing, generated) {
552
579
  const have = new Set(existing.split(/\r?\n/).map((line) => line.trim()))
553
- const missing = generated.split('\n')
554
- .map((line) => line.trim())
580
+ const missing = [...new Set(generated.split('\n').map((line) => line.trim()))]
555
581
  .filter((line) => line && !line.startsWith('#') && !have.has(line))
556
582
 
557
583
  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, {
@@ -305,18 +335,22 @@ function eraseText (io, printed) {
305
335
 
306
336
  function toOptions (answers) {
307
337
  const { directory, kind, language, middleware = [], extras = [] } = answers
308
- const view = kind === 'web' ? answers.view : false
309
- const session = kind === 'web' && middleware.includes('session')
338
+ const view = kind === 'api' ? false : answers.view
339
+ const session = kind !== 'api' && middleware.includes('session')
340
+ const csrf = session && view !== false && answers.csrf === true
310
341
 
311
342
  return {
312
343
  _: [directory.dir],
313
344
  '!': [],
314
- api: kind === 'api',
345
+ api: kind !== 'web',
315
346
  cjs: language === 'cjs',
316
347
  compression: middleware.includes('compression'),
317
348
  cookies: middleware.includes('cookies'),
318
349
  cors: middleware.includes('cors'),
319
- csrf: session && view !== false && answers.csrf === true,
350
+ // the choices of a web app with a JSON API
351
+ ...(kind === 'both' && middleware.includes('cors') && { corsScope: answers.corsScope ?? 'api' }),
352
+ csrf,
353
+ ...(kind === 'both' && csrf && { csrfScope: answers.csrfScope ?? 'all' }),
320
354
  docker: extras.includes('docker'),
321
355
  force: directory.force,
322
356
  git: answers.git,
@@ -345,7 +379,8 @@ export function toCommand (options) {
345
379
  const args = [pm, 'create', 'express-new@latest', quote(options._[0])]
346
380
  const flags = []
347
381
 
348
- if (options.api) flags.push('--api')
382
+ // a JSON API has no view engine, unless one is given, the default included
383
+ if (options.api) flags.push('--api', ...(options.view ? ['--view=' + options.view] : []))
349
384
  else if (options.view === false) flags.push('--no-view')
350
385
  else if (options.view !== 'pug') flags.push('--view=' + options.view)
351
386
 
@@ -355,9 +390,11 @@ export function toCommand (options) {
355
390
  if (options.compression) flags.push('--compression')
356
391
  if (options.cookies) flags.push('--cookies')
357
392
  if (options.cors) flags.push('--cors')
393
+ if (options.corsScope === 'all') flags.push('--cors-scope=all')
358
394
  if (options.rateLimit) flags.push('--rate-limit')
359
395
  if (options.session) flags.push('--session')
360
396
  if (options.csrf) flags.push('--csrf')
397
+ if (options.csrfScope === 'pages') flags.push('--csrf-scope=pages')
361
398
  if (options.uploads) flags.push('--uploads')
362
399
  if (options.logger && options.logger !== 'morgan') flags.push('--logger=' + options.logger)
363
400
  if (options.docker) flags.push('--docker')
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.2.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
+ }
@@ -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
  <% } -%>