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