1import fs from 'fs';
2import path from 'path';
3
4import { ExpoMiddleware } from './ExpoMiddleware';
5import { ServerRequest, ServerResponse } from './server.types';
6
7const debug = require('debug')('expo:start:server:middleware:createFile') as typeof console.log;
8
9export type TouchFileBody = { path: string; contents: string };
10
11/**
12 * Middleware for creating a file given a `POST` request with
13 * `{ contents: string, path: string }` in the body.
14 */
15export class CreateFileMiddleware extends ExpoMiddleware {
16  constructor(protected projectRoot: string) {
17    super(projectRoot, ['/_expo/touch']);
18  }
19
20  protected resolvePath(inputPath: string): string {
21    let resolvedPath = path.join(this.projectRoot, inputPath);
22    const extension = path.extname(resolvedPath);
23    if (extension === '.js') {
24      // Automatically convert JS files to TS files when added to a project
25      // with TypeScript.
26      const tsconfigPath = path.join(this.projectRoot, 'tsconfig.json');
27      if (fs.existsSync(tsconfigPath)) {
28        resolvedPath = resolvedPath.replace(/\.js$/, '.tsx');
29      }
30    }
31
32    return resolvedPath;
33  }
34
35  protected async parseRawBody(req: ServerRequest): Promise<TouchFileBody> {
36    const rawBody = await new Promise<string>((resolve, reject) => {
37      let body = '';
38      req.on('data', (chunk) => {
39        body += chunk.toString();
40      });
41      req.on('end', () => {
42        resolve(body);
43      });
44      req.on('error', (err) => {
45        reject(err);
46      });
47    });
48
49    const properties = JSON.parse(rawBody);
50    this.assertTouchFileBody(properties);
51
52    return properties;
53  }
54
55  private assertTouchFileBody(body: any): asserts body is TouchFileBody {
56    if (typeof body !== 'object' || body == null) {
57      throw new Error('Expected object');
58    }
59    if (typeof body.path !== 'string') {
60      throw new Error('Expected "path" in body to be string');
61    }
62    if (typeof body.contents !== 'string') {
63      throw new Error('Expected "contents" in body to be string');
64    }
65  }
66
67  async handleRequestAsync(req: ServerRequest, res: ServerResponse): Promise<void> {
68    if (req.method !== 'POST') {
69      res.statusCode = 405;
70      res.end('Method Not Allowed');
71      return;
72    }
73
74    let properties: TouchFileBody;
75
76    try {
77      properties = await this.parseRawBody(req);
78    } catch (e) {
79      debug('Error parsing request body', e);
80      res.statusCode = 400;
81      res.end('Bad Request');
82      return;
83    }
84
85    debug(`Requested: %O`, properties);
86
87    const resolvedPath = this.resolvePath(properties.path);
88
89    if (fs.existsSync(resolvedPath)) {
90      res.statusCode = 409;
91      res.end('File already exists.');
92      return;
93    }
94
95    debug(`Resolved path:`, resolvedPath);
96
97    try {
98      await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true });
99      await fs.promises.writeFile(resolvedPath, properties.contents, 'utf8');
100    } catch (e) {
101      debug('Error writing file', e);
102      res.statusCode = 500;
103      res.end('Error writing file.');
104      return;
105    }
106
107    debug(`File created`);
108    res.statusCode = 200;
109    res.end('OK');
110  }
111}
112