diff --git a/.changeset/perfect-pugs-smell.md b/.changeset/perfect-pugs-smell.md new file mode 100644 index 00000000..4230fff8 --- /dev/null +++ b/.changeset/perfect-pugs-smell.md @@ -0,0 +1,10 @@ +--- +'nxjs-runtime': patch +--- + +Overhaul filesystem operations: + + * Added `Switch.mkdirSync()`, `Switch.removeSync()`, `Switch.statSync()` + * Read operations return `null` for `ENOENT`, instead of throwing an error + * `Switch.remove()` and `Switch.removeSync()` work with directories, and delete recursively + * `Switch.writeFileSync()` creates parent directories recursively as needed diff --git a/apps/tests/src/switch.ts b/apps/tests/src/switch.ts index d449225c..8578e036 100644 --- a/apps/tests/src/switch.ts +++ b/apps/tests/src/switch.ts @@ -41,14 +41,16 @@ test('`Switch.readDirSync()` works on `romfs:/` path', () => { assert.ok(files.length > 0); }); -test('`Switch.readDirSync()` throws error when directory does not exist', () => { - let err: Error | undefined; - try { - Switch.readDirSync('romfs:/__does_not_exist__'); - } catch (_err) { - err = _err as Error; - } - assert.ok(err); +test('`Switch.readFileSync()` returns `null` when file does not exist', () => { + assert.equal(Switch.readFileSync('romfs:/__does_not_exist__'), null); +}); + +test('`Switch.readDirSync()` returns `null` when directory does not exist', () => { + assert.equal(Switch.readDirSync('romfs:/__does_not_exist__'), null); +}); + +test('`Switch.statSync()` returns `null` when file does not exist', () => { + assert.equal(Switch.statSync('romfs:/__does_not_exist__'), null); }); test('`Switch.resolveDns()` works', async () => { diff --git a/packages/runtime/src/$.ts b/packages/runtime/src/$.ts index 362cd1e5..624ed057 100644 --- a/packages/runtime/src/$.ts +++ b/packages/runtime/src/$.ts @@ -109,12 +109,15 @@ export interface Init { getSystemFont(): ArrayBuffer; // fs.c - readFile(cb: Callback, path: string): void; - readDirSync(path: string): string[]; - readFileSync(path: string): ArrayBuffer; - writeFileSync(path: string, data: ArrayBuffer): void; + mkdirSync(path: string, mode: number): number; + readDirSync(path: string): string[] | null; + readFile(cb: Callback, path: string): void; + readFileSync(path: string): ArrayBuffer | null; remove(cb: Callback, path: string): void; - stat(cb: Callback, path: string): void; + removeSync(path: string): void; + stat(cb: Callback, path: string): void; + statSync(path: string): Stats | null; + writeFileSync(path: string, data: ArrayBuffer): void; // image.c imageInit(c: ClassOf): void; diff --git a/packages/runtime/src/fetch/fetch.ts b/packages/runtime/src/fetch/fetch.ts index 3dd9760c..a639f0e5 100644 --- a/packages/runtime/src/fetch/fetch.ts +++ b/packages/runtime/src/fetch/fetch.ts @@ -230,11 +230,14 @@ async function fetchFile(req: Request, url: URL) { const path = url.protocol === 'file:' ? `sdmc:${url.pathname}` : url.href; // TODO: Use streaming FS interface const data = await readFile(path); - return new Response(data, { - headers: { - 'content-length': String(data.byteLength), - }, - }); + const headers = new Headers(); + let status = 200; + if (data) { + headers.set('content-length', String(data.byteLength)); + } else { + status = 404; + } + return new Response(data, { status, headers }); } const fetchers = new Map Promise>( diff --git a/packages/runtime/src/fs.ts b/packages/runtime/src/fs.ts index 57a2bfd1..b79edb10 100644 --- a/packages/runtime/src/fs.ts +++ b/packages/runtime/src/fs.ts @@ -3,6 +3,25 @@ import { bufferSourceToArrayBuffer, pathToString, toPromise } from './utils'; import { encoder } from './polyfills/text-encoder'; import type { PathLike } from './switch'; +/** + * Creates the directory at the provided `path`, as well as any necessary parent directories. + * + * @example + * + * ```typescript + * const count = Switch.mkdirSync('sdmc:/foo/bar/baz'); + * console.log(`Created ${count} directories`); + * // Created 3 directories + * ``` + * + * @param path Path of the directory to create. + * @param mode The file mode to set for the directories. Default: `0o777`. + * @returns The number of directories created. If the directory already exists, returns `0`. + */ +export function mkdirSync(path: PathLike, mode = 0o777) { + return $.mkdirSync(pathToString(path), mode); +} + /** * Returns a Promise which resolves to an `ArrayBuffer` containing * the contents of the file at `path`. @@ -51,6 +70,8 @@ export function readFileSync(path: PathLike) { /** * Synchronously writes the contents of `data` to the file at `path`. * + * @example + * * ```typescript * const appStateJson = JSON.stringify(appState); * Switch.writeFileSync('sdmc:/switch/awesome-app/state.json', appStateJson); @@ -63,15 +84,37 @@ export function writeFileSync(path: PathLike, data: string | BufferSource) { } /** - * Removes the file or directory specified by `path`. + * Synchronously removes the file or directory recursively specified by `path`. + * + * @param path File path to remove. + */ +export function removeSync(path: PathLike) { + return $.removeSync(pathToString(path)); +} + +/** + * Removes the file or directory recursively specified by `path`. + * + * @param path File path to remove. */ export function remove(path: PathLike) { return toPromise($.remove, pathToString(path)); } +/** + * + * @param path File path to retrieve file stats for. + * @returns Object containing the file stat information of `path`, or `null` if the file does not exist. + */ +export function statSync(path: PathLike) { + return $.statSync(pathToString(path)); +} + /** * Returns a Promise which resolves to an object containing * information about the file pointed to by `path`. + * + * @param path File path to retrieve file stats for. */ export function stat(path: PathLike) { return toPromise($.stat, pathToString(path)); diff --git a/packages/runtime/src/source-map.ts b/packages/runtime/src/source-map.ts index 5469ba07..85b49380 100644 --- a/packages/runtime/src/source-map.ts +++ b/packages/runtime/src/source-map.ts @@ -51,25 +51,31 @@ function filenameToTracer(filename: string) { tracer = null; const contentsBuffer = readFileSync(filename); - const contents = new TextDecoder().decode(contentsBuffer).trimEnd(); - const lastNewline = contents.lastIndexOf('\n'); - const lastLine = contents.slice(lastNewline + 1); - if (lastLine.startsWith(SOURCE_MAPPING_URL_PREFIX)) { - const sourceMappingURL = lastLine.slice( - SOURCE_MAPPING_URL_PREFIX.length - ); - let sourceMapBuffer: ArrayBuffer; - if (sourceMappingURL.startsWith('data:')) { - sourceMapBuffer = dataUriToBuffer(sourceMappingURL).buffer; - } else { - sourceMapBuffer = readFileSync(new URL(sourceMappingURL, filename)); + if (contentsBuffer) { + const contents = new TextDecoder().decode(contentsBuffer).trimEnd(); + const lastNewline = contents.lastIndexOf('\n'); + const lastLine = contents.slice(lastNewline + 1); + if (lastLine.startsWith(SOURCE_MAPPING_URL_PREFIX)) { + const sourceMappingURL = lastLine.slice( + SOURCE_MAPPING_URL_PREFIX.length + ); + let sourceMapBuffer: ArrayBuffer | null; + if (sourceMappingURL.startsWith('data:')) { + sourceMapBuffer = dataUriToBuffer(sourceMappingURL).buffer; + } else { + sourceMapBuffer = readFileSync( + new URL(sourceMappingURL, filename) + ); + } + if (sourceMapBuffer) { + const sourceMap: EncodedSourceMap = JSON.parse( + new TextDecoder().decode(sourceMapBuffer) + ); + tracer = new TraceMap(sourceMap); + } } - const sourceMap: EncodedSourceMap = JSON.parse( - new TextDecoder().decode(sourceMapBuffer) - ); - tracer = new TraceMap(sourceMap); + sourceMapCache.set(filename, tracer); } - sourceMapCache.set(filename, tracer); return tracer; } diff --git a/source/fs.c b/source/fs.c index 9667b4af..eefeb091 100644 --- a/source/fs.c +++ b/source/fs.c @@ -29,6 +29,119 @@ typedef struct const char *filename; } nx_fs_remove_async_t; +char *dirname(const char *path) +{ + if (path == NULL || *path == '\0') + { + return strdup("."); // Current directory for empty paths + } + + // Copy the path to avoid modifying the original + char *pathCopy = strdup(path); + if (pathCopy == NULL) + { + return NULL; // Failed to allocate memory + } + + // Handle URL-like paths + char *schemeEnd = strstr(pathCopy, ":/"); + char *start = schemeEnd ? schemeEnd + 2 : pathCopy; + + char *lastSlash = strrchr(start, '/'); + if (lastSlash != NULL) + { + if (lastSlash == start) + { + // Root directory + *(lastSlash + 1) = '\0'; + } + else + { + // Remove everything after the last slash + *lastSlash = '\0'; + } + } + else + { + if (schemeEnd) + { + // No slash after scheme, return scheme + *(schemeEnd + 2) = '\0'; + } + else + { + // No slashes in path, return "." + free(pathCopy); + return strdup("."); + } + } + + return pathCopy; +} + +int createDirectoryRecursively(char *path, mode_t mode) +{ + int created = 0; + + // If a URL path, move the `p` pointer to after the scheme (e.g. after "sdmc:/") + char *pathStart = strstr(path, ":/"); + char *p = pathStart ? pathStart + 2 : path; + + // Iterate through path segments and create directories as needed + for (; *p; p++) + { + if (*p == '/') + { + *p = 0; // Temporarily truncate + if (mkdir(path, mode) == 0) + { + created++; + } + else if (errno != EEXIST) + { + return -1; // Failed to create directory + } + *p = '/'; // Restore slash + } + } + + // Create the final directory + if (mkdir(path, mode) == 0) + { + created++; + } + else if (errno != EEXIST) + { + return -1; // Failed to create directory + } + + return created; +} + +JSValue nx_mkdir_sync(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) +{ + errno = 0; + mode_t mode; + if (JS_ToUint32(ctx, &mode, argv[1])) + { + return JS_EXCEPTION; + } + const char *path = JS_ToCString(ctx, argv[0]); + if (!path) + { + return JS_EXCEPTION; + } + int created = createDirectoryRecursively((char *)path, mode); + JS_FreeCString(ctx, path); + if (created == -1) + { + JSValue error = JS_NewError(ctx); + JS_DefinePropertyValueStr(ctx, error, "message", JS_NewString(ctx, strerror(errno)), JS_PROP_WRITABLE | JS_PROP_CONFIGURABLE); + return JS_Throw(ctx, error); + } + return JS_NewInt32(ctx, created); +} + JSValue nx_readdir_sync(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { errno = 0; @@ -38,12 +151,16 @@ JSValue nx_readdir_sync(JSContext *ctx, JSValueConst this_val, int argc, JSValue const char *path = JS_ToCString(ctx, argv[0]); if (!path) { - return JS_Throw(ctx, JS_NewError(ctx)); + return JS_EXCEPTION; } dir = opendir(path); JS_FreeCString(ctx, path); if (dir == NULL) { + if (errno == ENOENT) + { + return JS_NULL; + } JSValue error = JS_NewError(ctx); JS_DefinePropertyValueStr(ctx, error, "message", JS_NewString(ctx, strerror(errno)), JS_PROP_WRITABLE | JS_PROP_CONFIGURABLE); return JS_Throw(ctx, error); @@ -160,6 +277,11 @@ JSValue nx_read_file_sync(JSContext *ctx, JSValueConst this_val, int argc, JSVal FILE *file = fopen(filename, "rb"); if (file == NULL) { + if (errno == ENOENT) + { + JS_FreeCString(ctx, filename); + return JS_NULL; + } JS_ThrowTypeError(ctx, "%s: %s", strerror(errno), filename); JS_FreeCString(ctx, filename); return JS_EXCEPTION; @@ -174,7 +296,6 @@ JSValue nx_read_file_sync(JSContext *ctx, JSValueConst this_val, int argc, JSVal if (buffer == NULL) { fclose(file); - JS_ThrowOutOfMemory(ctx); return JS_EXCEPTION; } @@ -195,6 +316,21 @@ JSValue nx_write_file_sync(JSContext *ctx, JSValueConst this_val, int argc, JSVa { errno = 0; const char *filename = JS_ToCString(ctx, argv[0]); + + // Create any parent directories + char *dir = dirname(filename); + if (dir) + { + if (createDirectoryRecursively(dir, 0777) == -1) + { + JS_ThrowTypeError(ctx, "%s: %s", strerror(errno), filename); + free(dir); + JS_FreeCString(ctx, filename); + return JS_EXCEPTION; + } + free(dir); + } + FILE *file = fopen(filename, "w"); if (file == NULL) { @@ -209,7 +345,6 @@ JSValue nx_write_file_sync(JSContext *ctx, JSValueConst this_val, int argc, JSVa if (buffer == NULL) { fclose(file); - JS_ThrowOutOfMemory(ctx); return JS_EXCEPTION; } @@ -225,6 +360,19 @@ JSValue nx_write_file_sync(JSContext *ctx, JSValueConst this_val, int argc, JSVa return JS_UNDEFINED; } +JSValue statToObject(JSContext *ctx, struct stat *st) +{ + JSValue obj = JS_NewObject(ctx); + JS_SetPropertyStr(ctx, obj, "size", JS_NewInt32(ctx, st->st_size)); + JS_SetPropertyStr(ctx, obj, "mtime", JS_NewInt32(ctx, st->st_mtim.tv_sec)); + JS_SetPropertyStr(ctx, obj, "atime", JS_NewInt32(ctx, st->st_atim.tv_nsec)); + JS_SetPropertyStr(ctx, obj, "ctime", JS_NewInt32(ctx, st->st_ctim.tv_nsec)); + JS_SetPropertyStr(ctx, obj, "mode", JS_NewInt32(ctx, st->st_mode)); + JS_SetPropertyStr(ctx, obj, "uid", JS_NewInt32(ctx, st->st_uid)); + JS_SetPropertyStr(ctx, obj, "gid", JS_NewInt32(ctx, st->st_gid)); + return obj; +} + void nx_stat_do(nx_work_t *req) { nx_fs_stat_async_t *data = (nx_fs_stat_async_t *)req->data; @@ -246,15 +394,7 @@ void nx_stat_cb(JSContext *ctx, nx_work_t *req, JSValue *args) return; } - JSValue stat = JS_NewObject(ctx); - JS_SetPropertyStr(ctx, stat, "size", JS_NewInt32(ctx, data->st.st_size)); - JS_SetPropertyStr(ctx, stat, "mtime", JS_NewInt32(ctx, data->st.st_mtim.tv_sec)); - JS_SetPropertyStr(ctx, stat, "atime", JS_NewInt32(ctx, data->st.st_atim.tv_nsec)); - JS_SetPropertyStr(ctx, stat, "ctime", JS_NewInt32(ctx, data->st.st_ctim.tv_nsec)); - JS_SetPropertyStr(ctx, stat, "mode", JS_NewInt32(ctx, data->st.st_mode)); - JS_SetPropertyStr(ctx, stat, "uid", JS_NewInt32(ctx, data->st.st_uid)); - JS_SetPropertyStr(ctx, stat, "gid", JS_NewInt32(ctx, data->st.st_gid)); - args[1] = stat; + args[1] = statToObject(ctx, &data->st); } JSValue nx_stat(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) @@ -265,10 +405,85 @@ JSValue nx_stat(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *a return JS_UNDEFINED; } +JSValue nx_stat_sync(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) +{ + const char *filename = JS_ToCString(ctx, argv[1]); + struct stat st; + int result = stat(filename, &st); + JS_FreeCString(ctx, filename); + if (result != 0) + { + if (errno == ENOENT) + { + return JS_NULL; + } + JSValue error = JS_NewError(ctx); + JS_DefinePropertyValueStr(ctx, error, "message", JS_NewString(ctx, strerror(errno)), JS_PROP_WRITABLE | JS_PROP_CONFIGURABLE); + return JS_Throw(ctx, error); + } + return statToObject(ctx, &st); +} + +int removeDirectory(const char *path) +{ + DIR *d = opendir(path); + size_t path_len = strlen(path); + int r = -1; + + if (d) + { + struct dirent *p; + r = 0; + + while (!r && (p = readdir(d))) + { + int r2 = -1; + char *buf; + size_t len; + + // Skip the names "." and ".." as we don't want to recurse on them. + if (!strcmp(p->d_name, ".") || !strcmp(p->d_name, "..")) + { + continue; + } + + len = path_len + strlen(p->d_name) + 2; + buf = malloc(len); + + if (buf) + { + struct stat statbuf; + + snprintf(buf, len, "%s/%s", path, p->d_name); + if (!stat(buf, &statbuf)) + { + if (S_ISDIR(statbuf.st_mode)) + r2 = removeDirectory(buf); + else + r2 = unlink(buf); + } + + free(buf); + } + + r = r2; + } + + closedir(d); + } + + if (!r) + { + r = rmdir(path); + } + + return r; +} + void nx_remove_do(nx_work_t *req) { nx_fs_remove_async_t *data = (nx_fs_remove_async_t *)req->data; - if (remove(data->filename) != 0) + if (removeDirectory(data->filename) != 0) { data->err = errno; } @@ -290,18 +505,41 @@ JSValue nx_remove(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst { NX_INIT_WORK_T(nx_fs_remove_async_t); data->filename = JS_ToCString(ctx, argv[1]); + if (!data->filename) + { + return JS_EXCEPTION; + } nx_queue_async(ctx, req, nx_remove_do, nx_remove_cb, argv[0]); return JS_UNDEFINED; } +JSValue nx_remove_sync(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) +{ + const char *path = JS_ToCString(ctx, argv[1]); + if (!path) + return JS_EXCEPTION; + int result = removeDirectory(path); + JS_FreeCString(ctx, path); + if (result != 0) + { + JSValue error = JS_NewError(ctx); + JS_DefinePropertyValueStr(ctx, error, "message", JS_NewString(ctx, strerror(errno)), JS_PROP_WRITABLE | JS_PROP_CONFIGURABLE); + return JS_Throw(ctx, error); + } + return JS_UNDEFINED; +} + static const JSCFunctionListEntry function_list[] = { - // JS_CFUNC_DEF("readDir", 0, nx_readdir), - JS_CFUNC_DEF("readFile", 2, nx_read_file), + JS_CFUNC_DEF("mkdirSync", 1, nx_mkdir_sync), JS_CFUNC_DEF("readDirSync", 1, nx_readdir_sync), + JS_CFUNC_DEF("readFile", 2, nx_read_file), JS_CFUNC_DEF("readFileSync", 1, nx_read_file_sync), - JS_CFUNC_DEF("writeFileSync", 1, nx_write_file_sync), + JS_CFUNC_DEF("remove", 2, nx_remove), + JS_CFUNC_DEF("removeSync", 2, nx_remove_sync), JS_CFUNC_DEF("stat", 2, nx_stat), - JS_CFUNC_DEF("remove", 2, nx_remove)}; + JS_CFUNC_DEF("statSync", 2, nx_stat_sync), + JS_CFUNC_DEF("writeFileSync", 1, nx_write_file_sync), +}; void nx_init_fs(JSContext *ctx, JSValueConst init_obj) {