spooder 3.0.8 → 3.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spooder",
3
3
  "type": "module",
4
- "version": "3.0.8",
4
+ "version": "3.2.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "bun": "./src/api.ts",
@@ -15,5 +15,8 @@
15
15
  },
16
16
  "bin": {
17
17
  "spooder": "./src/cli.ts"
18
+ },
19
+ "dependencies": {
20
+ "@octokit/app": "^13.1.5"
18
21
  }
19
22
  }
package/src/api.d.ts CHANGED
@@ -1 +1,28 @@
1
- export declare function test_api(): void;
1
+ /// <reference types="bun-types" />
2
+ /// <reference types="node" />
3
+ export declare function panic(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
4
+ export declare function caution(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
5
+ type HandlerReturnType = any;
6
+ type RequestHandler = (req: Request, url: URL) => HandlerReturnType;
7
+ type ErrorHandler = (err: Error) => Response;
8
+ type DefaultHandler = (req: Request, status_code: number) => HandlerReturnType;
9
+ type StatusCodeHandler = (req: Request) => HandlerReturnType;
10
+ type DirOptions = {
11
+ ignoreHidden?: boolean;
12
+ index?: string;
13
+ };
14
+ /** Built-in route handler for redirecting to a different URL. */
15
+ export declare function route_location(redirect_url: string): (req: Request, url: URL) => Response;
16
+ export declare function serve(port: number): {
17
+ /** Register a handler for a specific route. */
18
+ route: (path: string, handler: RequestHandler) => void;
19
+ /** Serve a directory for a specific route. */
20
+ dir: (path: string, dir: string, options?: DirOptions) => void;
21
+ /** Register a default handler for all status codes. */
22
+ default: (handler: DefaultHandler) => void;
23
+ /** Register a handler for a specific status code. */
24
+ handle: (status_code: number, handler: StatusCodeHandler) => void;
25
+ /** Register a handler for uncaught errors. */
26
+ error: (handler: ErrorHandler) => void;
27
+ };
28
+ export {};
package/src/api.ts CHANGED
@@ -1,3 +1,243 @@
1
- export function test_api() {
2
- console.log('test_api :: hello world!');
1
+ import { dispatch_report } from './dispatch';
2
+ import http from 'node:http';
3
+ import path from 'node:path';
4
+ import fs from 'node:fs/promises';
5
+
6
+ async function handle_error(prefix: string, err_message_or_obj: string | object, ...err: unknown[]): Promise<void> {
7
+ let error_message = 'unknown error';
8
+
9
+ if (typeof err_message_or_obj === 'string') {
10
+ error_message = err_message_or_obj;
11
+ err.unshift(error_message);
12
+ } else {
13
+ if (err_message_or_obj instanceof Error)
14
+ error_message = err_message_or_obj.message;
15
+
16
+ err.push(err_message_or_obj);
17
+ }
18
+
19
+ // Serialize error objects.
20
+ err = err.map(e => {
21
+ if (e instanceof Error) {
22
+ return {
23
+ name: e.name,
24
+ message: e.message,
25
+ stack: e.stack?.split('\n') ?? []
26
+ }
27
+ }
28
+
29
+ return e;
30
+ })
31
+
32
+ await dispatch_report(prefix + error_message, err);
33
+ }
34
+
35
+ export async function panic(err_message_or_obj: string | object, ...err: object[]): Promise<void> {
36
+ await handle_error('panic: ', err_message_or_obj, ...err);
37
+ process.exit(1);
38
+ }
39
+
40
+ export async function caution(err_message_or_obj: string | object, ...err: object[]): Promise<void> {
41
+ await handle_error('caution: ', err_message_or_obj, ...err);
42
+ }
43
+
44
+ type HandlerReturnType = any;
45
+ type RequestHandler = (req: Request, url: URL) => HandlerReturnType;
46
+ type ErrorHandler = (err: Error) => Response;
47
+ type DefaultHandler = (req: Request, status_code: number) => HandlerReturnType;
48
+ type StatusCodeHandler = (req: Request) => HandlerReturnType;
49
+
50
+ type DirOptions = {
51
+ ignoreHidden?: boolean;
52
+ index?: string;
53
+ };
54
+
55
+ /** Built-in route handler for redirecting to a different URL. */
56
+ export function route_location(redirect_url: string) {
57
+ return (req: Request, url: URL) => {
58
+ return new Response(null, {
59
+ status: 301,
60
+ headers: {
61
+ Location: redirect_url
62
+ }
63
+ });
64
+ };
65
+ }
66
+
67
+ function route_directory(route_path: string, dir: string, options: DirOptions): RequestHandler {
68
+ const ignore_hidden = options.ignoreHidden ?? true;
69
+
70
+ return async (req: Request, url: URL) => {
71
+ const file_path = path.join(dir, url.pathname.slice(route_path.length));
72
+
73
+ if (ignore_hidden && path.basename(file_path).startsWith('.'))
74
+ return 404;
75
+
76
+ try {
77
+ const file_stat = await fs.stat(file_path);
78
+
79
+ if (file_stat.isDirectory()) {
80
+ if (options.index !== undefined) {
81
+ const index_path = path.join(file_path, options.index);
82
+ const index = Bun.file(index_path);
83
+
84
+ if (index.size !== 0)
85
+ return index;
86
+ }
87
+ return 401;
88
+ }
89
+
90
+ return Bun.file(file_path);
91
+ } catch (e) {
92
+ const err = e as NodeJS.ErrnoException;
93
+ if (err?.code === 'ENOENT')
94
+ return 404;
95
+
96
+ return 500;
97
+ }
98
+ };
99
+ }
100
+
101
+ export function serve(port: number) {
102
+ const routes = new Map<string[], RequestHandler>();
103
+ const handlers = new Map<number, StatusCodeHandler>();
104
+
105
+ let error_handler: ErrorHandler | undefined;
106
+ let default_handler: DefaultHandler | undefined;
107
+
108
+ async function resolve_handler(response: HandlerReturnType | Promise<HandlerReturnType>, status_code: number, return_status_code = false): Promise<Response | number> {
109
+ if (response instanceof Promise)
110
+ response = await response;
111
+
112
+ // Pre-assembled responses are returned as-is.
113
+ if (response instanceof Response)
114
+ return response;
115
+
116
+ // Content-type/content-length are automatically set for blobs.
117
+ if (response instanceof Blob)
118
+ return new Response(response, { status: status_code });
119
+
120
+ // Status codes can be returned from some handlers.
121
+ if (return_status_code && typeof response === 'number')
122
+ return response;
123
+
124
+ // This should cover objects, arrays, etc.
125
+ if (typeof response === 'object')
126
+ return new Response(JSON.stringify(response), { status: status_code, headers: { 'Content-Type': 'application/json' } });
127
+
128
+ return new Response(String(response), { status: status_code })
129
+ }
130
+
131
+ const server = Bun.serve({
132
+ port,
133
+ development: false,
134
+
135
+ async fetch(req: Request): Promise<Response> {
136
+ const url = new URL(req.url);
137
+ let status_code = 200;
138
+
139
+ console.log(`${req.method} ${url.pathname}`);
140
+
141
+ const route_array = url.pathname.split('/').filter(e => !(e === '..' || e === '.'));
142
+ let handler: RequestHandler | undefined;
143
+
144
+ for (const [path, route_handler] of routes) {
145
+ const is_trailing_wildcard = path[path.length - 1] === '*';
146
+ if (!is_trailing_wildcard && path.length !== route_array.length)
147
+ continue;
148
+
149
+ let match = true;
150
+ for (let i = 0; i < path.length; i++) {
151
+ const path_part = path[i];
152
+
153
+ if (path_part === '*')
154
+ continue;
155
+
156
+ if (path_part.startsWith(':')) {
157
+ url.searchParams.append(path_part.slice(1), route_array[i]);
158
+ continue;
159
+ }
160
+
161
+ if (path_part !== route_array[i]) {
162
+ match = false;
163
+ break;
164
+ }
165
+ }
166
+
167
+ if (match) {
168
+ handler = route_handler;
169
+ break;
170
+ }
171
+ }
172
+
173
+ // Check for a handler for the route.
174
+ if (handler !== undefined) {
175
+ const response = await resolve_handler(handler(req, url), status_code, true);
176
+ if (response instanceof Response)
177
+ return response;
178
+
179
+ // If the handler returned a status code, use that instead.
180
+ status_code = response;
181
+ } else {
182
+ status_code = 404;
183
+ }
184
+
185
+ // Fallback to checking for a handler for the status code.
186
+ const status_code_handler = handlers.get(status_code);
187
+ if (status_code_handler !== undefined) {
188
+ const response = await resolve_handler(status_code_handler(req), status_code);
189
+ if (response instanceof Response)
190
+ return response;
191
+ }
192
+
193
+ // Fallback to the default handler, if any.
194
+ if (default_handler !== undefined) {
195
+ const response = await resolve_handler(default_handler(req, status_code), status_code);
196
+ if (response instanceof Response)
197
+ return response;
198
+ }
199
+
200
+ // Fallback to returning a basic response.
201
+ return new Response(http.STATUS_CODES[status_code], { status: status_code });
202
+ },
203
+
204
+ error(err: Error): Response {
205
+ if (error_handler !== undefined)
206
+ return error_handler(err);
207
+
208
+ return new Response(http.STATUS_CODES[500], { status: 500 });
209
+ }
210
+ });
211
+
212
+ console.log(`Server started on port ${port}`);
213
+
214
+ return {
215
+ /** Register a handler for a specific route. */
216
+ route: (path: string, handler: RequestHandler): void => {
217
+ routes.set(path.split('/'), handler);
218
+ },
219
+
220
+ /** Serve a directory for a specific route. */
221
+ dir: (path: string, dir: string, options?: DirOptions): void => {
222
+ if (path.endsWith('/'))
223
+ path = path.slice(0, -1);
224
+
225
+ routes.set([...path.split('/'), '*'], route_directory(path, dir, options ?? {}));
226
+ },
227
+
228
+ /** Register a default handler for all status codes. */
229
+ default: (handler: DefaultHandler): void => {
230
+ default_handler = handler;
231
+ },
232
+
233
+ /** Register a handler for a specific status code. */
234
+ handle: (status_code: number, handler: StatusCodeHandler): void => {
235
+ handlers.set(status_code, handler);
236
+ },
237
+
238
+ /** Register a handler for uncaught errors. */
239
+ error: (handler: ErrorHandler): void => {
240
+ error_handler = handler;
241
+ }
242
+ }
3
243
  }
package/src/cli.ts CHANGED
@@ -1,87 +1,25 @@
1
1
  #!/usr/bin/env bun
2
- import path from 'node:path';
3
-
4
- type Config = Record<string, unknown>;
5
-
6
- async function load_config(): Promise<Config> {
7
- try {
8
- const config_file = Bun.file(path.join(process.cwd(), 'package.json'));
9
- const json = await config_file.json();
10
-
11
- return json?.spooder ?? {};
12
- } catch (e) {
13
- return {};
14
- }
15
- }
16
-
17
- function parse_command(command: string): string[] {
18
- const args = [];
19
- let current_arg = '';
20
- let in_quotes = false;
21
- let in_escape = false;
22
-
23
- for (let i = 0; i < command.length; i++) {
24
- const char = command[i];
25
-
26
- if (in_escape) {
27
- current_arg += char;
28
- in_escape = false;
29
- continue;
30
- }
31
-
32
- if (char === '\\') {
33
- in_escape = true;
34
- continue;
35
- }
36
-
37
- if (char === '"') {
38
- in_quotes = !in_quotes;
39
- continue;
40
- }
41
-
42
- if (char === ' ' && !in_quotes) {
43
- args.push(current_arg);
44
- current_arg = '';
45
- continue;
46
- }
47
-
48
- current_arg += char;
49
- }
50
-
51
- if (current_arg.length > 0)
52
- args.push(current_arg);
53
-
54
- return args;
55
- }
56
-
57
- function log(message: string, ...args: unknown[]): void {
58
- console.log('[spooder] ' + message, ...args);
59
- }
60
-
61
- const config = await load_config();
62
- const config_run_command = config.run as string ?? 'bun run index.ts';
63
- const config_auto_restart_ms = config.autoRestart as number ?? 5000;
64
-
65
- let config_update_commands = [] as string[];
66
- if (config.update) {
67
- if (typeof config.update === 'string')
68
- config_update_commands = [config.update]
69
- else if (Array.isArray(config.update))
70
- config_update_commands = config.update;
71
- }
2
+ import { get_config } from './config';
3
+ import { parse_command_line, log, strip_color_codes } from './utils';
4
+ import { dispatch_report } from './dispatch';
72
5
 
73
6
  async function start_server() {
74
7
  log('start_server');
75
8
 
76
- if (config_update_commands.length > 0) {
77
- log('running %d update commands', config_update_commands.length);
9
+ const config = await get_config();
10
+
11
+ const update_commands = config.update;
12
+ const n_update_commands = update_commands.length;
78
13
 
79
- for (let i = 0; i < config_update_commands.length; i++) {
80
- const config_update_command = config_update_commands[i];
14
+ if (n_update_commands > 0) {
15
+ log('running %d update commands', n_update_commands);
16
+
17
+ for (let i = 0; i < n_update_commands; i++) {
18
+ const config_update_command = update_commands[i];
81
19
 
82
20
  log('[%d] %s', i, config_update_command);
83
21
 
84
- const update_proc = Bun.spawn(parse_command(config_update_command), {
22
+ const update_proc = Bun.spawn(parse_command_line(config_update_command), {
85
23
  cwd: process.cwd(),
86
24
  stdout: 'inherit',
87
25
  stderr: 'inherit'
@@ -98,17 +36,35 @@ async function start_server() {
98
36
  }
99
37
  }
100
38
 
101
- Bun.spawn(parse_command(config_run_command), {
39
+ Bun.spawn(parse_command_line(config.run), {
102
40
  cwd: process.cwd(),
103
41
  stdout: 'inherit',
104
- stderr: 'inherit',
42
+ stderr: 'pipe',
105
43
 
106
44
  onExit: (proc, exitCode, signal) => {
107
45
  log('server exited with code %d', exitCode);
108
46
 
109
- if (config_auto_restart_ms > -1) {
110
- log('restarting server in %dms', config_auto_restart_ms);
111
- setTimeout(start_server, config_auto_restart_ms);
47
+ if (exitCode !== null && exitCode > 0) {
48
+ if (proc.stderr !== undefined) {
49
+ const res = new Response(proc.stderr as ReadableStream);
50
+
51
+ res.text().then(async stderr => {
52
+ await dispatch_report('crash: server exited unexpectedly', [{
53
+ exitCode,
54
+ stderr: strip_color_codes(stderr).split(/\r?\n/)
55
+ }]);
56
+ });
57
+ } else {
58
+ dispatch_report('crash: service exited unexpectedly', [{
59
+ exitCode
60
+ }]);
61
+ }
62
+ }
63
+
64
+ const auto_restart_ms = config.autoRestart;
65
+ if (auto_restart_ms > -1) {
66
+ log('restarting server in %dms', auto_restart_ms);
67
+ setTimeout(start_server, auto_restart_ms);
112
68
  }
113
69
  }
114
70
  });
@@ -0,0 +1,15 @@
1
+ declare const internal_config: {
2
+ run: string;
3
+ autoRestart: number;
4
+ update: never[];
5
+ canary: {
6
+ account: string;
7
+ repository: string;
8
+ labels: never[];
9
+ throttle: number;
10
+ sanitize: boolean;
11
+ };
12
+ };
13
+ type Config = typeof internal_config;
14
+ export declare function get_config(): Promise<Config>;
15
+ export {};
package/src/config.ts ADDED
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import { warn } from './utils';
3
+
4
+ const internal_config = {
5
+ run: 'bun run index.ts',
6
+ autoRestart: -1,
7
+ update: [],
8
+ canary: {
9
+ account: '',
10
+ repository: '',
11
+ labels: [],
12
+ throttle: 86400,
13
+ sanitize: true
14
+ }
15
+ };
16
+
17
+ type Config = typeof internal_config;
18
+ type ConfigObject = Record<string, unknown>;
19
+
20
+ function validate_config_option(source: ConfigObject, target: ConfigObject, root_name: string) {
21
+ for (const [key, value] of Object.entries(target)) {
22
+ const key_name = `${root_name}.${key}`;
23
+ if (key in source) {
24
+ const default_value = source[key as keyof Config];
25
+ const expected_type = typeof default_value;
26
+
27
+ const actual_type = typeof value;
28
+
29
+ if (actual_type !== expected_type) {
30
+ warn('ignoring invalid configuration value `%s` (expected %s, got %s)', key_name, expected_type, actual_type);
31
+ continue;
32
+ }
33
+
34
+ if (actual_type === 'object') {
35
+ const is_default_array = Array.isArray(default_value);
36
+ const is_actual_array = Array.isArray(value);
37
+
38
+ if (is_default_array) {
39
+ if (!is_actual_array) {
40
+ warn('ignoring invalid configuration value `%s` (expected array)', key_name);
41
+ continue;
42
+ }
43
+
44
+ source[key as keyof Config] = value as Config[keyof Config];
45
+ } else {
46
+ if (is_actual_array) {
47
+ warn('ignoring invalid configuration value `%s` (expected object)', key_name);
48
+ continue;
49
+ }
50
+
51
+ validate_config_option(default_value as ConfigObject, value as ConfigObject, key_name);
52
+ }
53
+ } else {
54
+ source[key as keyof Config] = value as Config[keyof Config];
55
+ }
56
+ } else {
57
+ warn('ignoring unknown configuration key `%s`', key_name);
58
+ }
59
+ }
60
+ }
61
+
62
+ export async function get_config(): Promise<Config> {
63
+ try {
64
+ const config_file = Bun.file(path.join(process.cwd(), 'package.json'));
65
+ const json = await config_file.json();
66
+
67
+ if (json.spooder === null || typeof json.spooder !== 'object') {
68
+ warn('failed to parse spooder configuration in package.json, using defaults');
69
+ return internal_config;
70
+ }
71
+
72
+ validate_config_option(internal_config, json.spooder, 'spooder');
73
+ } catch (e) {
74
+ warn('failed to read package.json, using configuration defaults');
75
+ }
76
+
77
+ return internal_config;
78
+ }
@@ -0,0 +1 @@
1
+ export declare function dispatch_report(report_title: string, report_body: Array<unknown>): Promise<void>;