fluxion-ts 0.0.4
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/.oxlintrc.json +64 -0
- package/.prettierrc +6 -0
- package/AGENTS.md +3 -0
- package/Dockerfile +13 -0
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/document/index.html +16 -0
- package/document/src/main.tsx +8 -0
- package/document/src/styles.css +321 -0
- package/document/src/view/App.tsx +304 -0
- package/document/src/view/CodeBlock.tsx +11 -0
- package/document/src/view/Section.tsx +18 -0
- package/document/vite.config.ts +23 -0
- package/draft/vibe.md +50 -0
- package/package.json +66 -0
- package/rollup.config.mjs +102 -0
- package/scripts/build-image.ts +13 -0
- package/scripts/bump-version.ts +12 -0
- package/scripts/configs.ts +79 -0
- package/scripts/lines.ts +54 -0
- package/scripts/publish.ts +6 -0
- package/src/common/consts.ts +30 -0
- package/src/common/dtm.ts +10 -0
- package/src/common/logger.ts +145 -0
- package/src/core/meta-api.ts +48 -0
- package/src/core/server.ts +447 -0
- package/src/core/types.d.ts +6 -0
- package/src/core/utils/headers.ts +34 -0
- package/src/core/utils/request.ts +145 -0
- package/src/core/utils/send-json.ts +21 -0
- package/src/index.ts +11 -0
- package/src/workers/file-runtime.ts +1071 -0
- package/src/workers/handler-worker-pool.ts +754 -0
- package/src/workers/handler-worker.ts +1029 -0
- package/src/workers/options.ts +77 -0
- package/src/workers/protocol.d.ts +186 -0
- package/tests/core/dynamic-directory.test.ts +48 -0
- package/tests/core/file-runtime.test.ts +347 -0
- package/tests/core/server-options.test.ts +204 -0
- package/tests/e2e/fluxion-server.e2e-spec.ts +225 -0
- package/tests/helpers/test-utils.ts +81 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +24 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker runtime tuning options.
|
|
3
|
+
*/
|
|
4
|
+
export interface ExecutorOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Request timeout in milliseconds.
|
|
7
|
+
*/
|
|
8
|
+
requestTimeoutMs: number;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum concurrent requests allowed in the pool.
|
|
11
|
+
*/
|
|
12
|
+
maxInflight: number;
|
|
13
|
+
/**
|
|
14
|
+
* Soft heap threshold in MB. Idle worker may restart after crossing it.
|
|
15
|
+
*/
|
|
16
|
+
memorySoftLimitMb: number;
|
|
17
|
+
/**
|
|
18
|
+
* ! Hard heap threshold in MB. Worker is restarted once reached.
|
|
19
|
+
*/
|
|
20
|
+
memoryHardLimitMb: number;
|
|
21
|
+
/**
|
|
22
|
+
* Memory telemetry interval in milliseconds.
|
|
23
|
+
*/
|
|
24
|
+
memorySampleIntervalMs: number;
|
|
25
|
+
/**
|
|
26
|
+
* ! V8 old-generation limit per worker in MB.
|
|
27
|
+
*/
|
|
28
|
+
maxOldGenerationSizeMb: number;
|
|
29
|
+
/**
|
|
30
|
+
* ! V8 young-generation limit per worker in MB.
|
|
31
|
+
*/
|
|
32
|
+
maxYoungGenerationSizeMb: number;
|
|
33
|
+
/**
|
|
34
|
+
* Worker stack size in MB.
|
|
35
|
+
*/
|
|
36
|
+
stackSizeMb: number;
|
|
37
|
+
/**
|
|
38
|
+
* ! Maximum response payload bytes allowed from worker to main thread.
|
|
39
|
+
*/
|
|
40
|
+
maxResponseBytes: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Custom worker item used by `workerStrategy`.
|
|
45
|
+
*/
|
|
46
|
+
export interface WorkerStrategyCustomItem extends Partial<ExecutorOptions> {
|
|
47
|
+
/**
|
|
48
|
+
* Stable worker id.
|
|
49
|
+
*/
|
|
50
|
+
id: string;
|
|
51
|
+
/**
|
|
52
|
+
* Database names this worker can access.
|
|
53
|
+
*/
|
|
54
|
+
db: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Worker strategy selector.
|
|
59
|
+
*/
|
|
60
|
+
export type WorkerStrategy = 'all' | WorkerStrategyCustomItem[];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolves runtime options with framework defaults.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveExecutorOptions(overrides: Partial<ExecutorOptions> = {}): ExecutorOptions {
|
|
66
|
+
return {
|
|
67
|
+
requestTimeoutMs: overrides.requestTimeoutMs ?? 3000,
|
|
68
|
+
maxInflight: overrides.maxInflight ?? 64,
|
|
69
|
+
memorySoftLimitMb: overrides.memorySoftLimitMb ?? 96,
|
|
70
|
+
memoryHardLimitMb: overrides.memoryHardLimitMb ?? 128,
|
|
71
|
+
memorySampleIntervalMs: overrides.memorySampleIntervalMs ?? 5000,
|
|
72
|
+
maxOldGenerationSizeMb: overrides.maxOldGenerationSizeMb ?? 128,
|
|
73
|
+
maxYoungGenerationSizeMb: overrides.maxYoungGenerationSizeMb ?? 32,
|
|
74
|
+
stackSizeMb: overrides.stackSizeMb ?? 4,
|
|
75
|
+
maxResponseBytes: overrides.maxResponseBytes ?? 2 * 1024 * 1024,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC protocol between main thread and runtime worker.
|
|
3
|
+
*/
|
|
4
|
+
export namespace protocol {
|
|
5
|
+
/**
|
|
6
|
+
* Supported runtime db drivers.
|
|
7
|
+
*/
|
|
8
|
+
export type DbDriver = 'pg' | 'mysql2';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalized db connection config pushed from main thread to workers.
|
|
12
|
+
*/
|
|
13
|
+
export interface WorkerDbConnectionConfig {
|
|
14
|
+
/**
|
|
15
|
+
* Driver used to initialize the connection pool.
|
|
16
|
+
*/
|
|
17
|
+
driver: DbDriver;
|
|
18
|
+
/**
|
|
19
|
+
* Optional target context key for db module injection.
|
|
20
|
+
*/
|
|
21
|
+
contextKey?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Driver-specific pool options.
|
|
24
|
+
*/
|
|
25
|
+
options: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Worker db config map keyed by database name.
|
|
30
|
+
*/
|
|
31
|
+
export type WorkerDbConfigMap = Record<string, WorkerDbConnectionConfig>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* HTTP header value.
|
|
35
|
+
*/
|
|
36
|
+
export type HeaderValue = string | string[];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* HTTP header map.
|
|
40
|
+
*/
|
|
41
|
+
export type Headers = Record<string, HeaderValue>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Execute payload sent to worker.
|
|
45
|
+
*/
|
|
46
|
+
export interface Payload {
|
|
47
|
+
/**
|
|
48
|
+
* Absolute path of the handler file.
|
|
49
|
+
*/
|
|
50
|
+
filePath: string;
|
|
51
|
+
/**
|
|
52
|
+
* Version token generated from file metadata.
|
|
53
|
+
*/
|
|
54
|
+
version: string;
|
|
55
|
+
/**
|
|
56
|
+
* HTTP method.
|
|
57
|
+
*/
|
|
58
|
+
method: string;
|
|
59
|
+
/**
|
|
60
|
+
* Raw request target (pathname + query).
|
|
61
|
+
*/
|
|
62
|
+
url: string;
|
|
63
|
+
/**
|
|
64
|
+
* Request headers.
|
|
65
|
+
*/
|
|
66
|
+
headers: Headers;
|
|
67
|
+
/**
|
|
68
|
+
* Request body as binary payload.
|
|
69
|
+
*/
|
|
70
|
+
body?: Uint8Array;
|
|
71
|
+
/**
|
|
72
|
+
* Client ip captured in main thread.
|
|
73
|
+
*/
|
|
74
|
+
ip: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Main -> worker execute command.
|
|
79
|
+
*/
|
|
80
|
+
export interface ExecuteMessage {
|
|
81
|
+
type: 'execute';
|
|
82
|
+
/**
|
|
83
|
+
* Correlation id for this request.
|
|
84
|
+
*/
|
|
85
|
+
id: string;
|
|
86
|
+
payload: Payload;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Worker response payload to be applied on ServerResponse.
|
|
91
|
+
*/
|
|
92
|
+
export interface SerializedResponse {
|
|
93
|
+
/**
|
|
94
|
+
* HTTP status code.
|
|
95
|
+
*/
|
|
96
|
+
statusCode: number;
|
|
97
|
+
/**
|
|
98
|
+
* Serialized response headers.
|
|
99
|
+
*/
|
|
100
|
+
headers: Record<string, string>;
|
|
101
|
+
/**
|
|
102
|
+
* Optional response body bytes.
|
|
103
|
+
*/
|
|
104
|
+
body?: Uint8Array;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Serialized runtime error.
|
|
109
|
+
*/
|
|
110
|
+
export interface SerializedError {
|
|
111
|
+
/**
|
|
112
|
+
* Error class name.
|
|
113
|
+
*/
|
|
114
|
+
name: string;
|
|
115
|
+
/**
|
|
116
|
+
* Error message.
|
|
117
|
+
*/
|
|
118
|
+
message: string;
|
|
119
|
+
/**
|
|
120
|
+
* Optional stack trace.
|
|
121
|
+
*/
|
|
122
|
+
stack?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Optional error code.
|
|
125
|
+
*/
|
|
126
|
+
code?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Worker -> main result event.
|
|
131
|
+
*/
|
|
132
|
+
export interface ResultMessage {
|
|
133
|
+
type: 'result';
|
|
134
|
+
/**
|
|
135
|
+
* Correlation id matching ExecuteMessage.id.
|
|
136
|
+
*/
|
|
137
|
+
id: string;
|
|
138
|
+
/**
|
|
139
|
+
* Whether execution succeeded.
|
|
140
|
+
*/
|
|
141
|
+
ok: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Handler execution time in milliseconds.
|
|
144
|
+
*/
|
|
145
|
+
elapsedMs: number;
|
|
146
|
+
/**
|
|
147
|
+
* Heap used when result is produced.
|
|
148
|
+
*/
|
|
149
|
+
heapUsed: number;
|
|
150
|
+
response?: SerializedResponse;
|
|
151
|
+
error?: SerializedError;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Worker -> main periodic memory report.
|
|
156
|
+
*/
|
|
157
|
+
export interface MemoryMessage {
|
|
158
|
+
type: 'memory';
|
|
159
|
+
/**
|
|
160
|
+
* V8 heap used bytes.
|
|
161
|
+
*/
|
|
162
|
+
heapUsed: number;
|
|
163
|
+
/**
|
|
164
|
+
* Resident set size bytes.
|
|
165
|
+
*/
|
|
166
|
+
rss: number;
|
|
167
|
+
/**
|
|
168
|
+
* External memory bytes.
|
|
169
|
+
*/
|
|
170
|
+
external: number;
|
|
171
|
+
/**
|
|
172
|
+
* ArrayBuffer memory bytes.
|
|
173
|
+
*/
|
|
174
|
+
arrayBuffers: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Union of commands accepted by worker.
|
|
179
|
+
*/
|
|
180
|
+
export type InboundMessage = ExecuteMessage;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Union of events emitted by worker.
|
|
184
|
+
*/
|
|
185
|
+
export type OutboundMessage = ResultMessage | MemoryMessage;
|
|
186
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createTempDirectory, ensureDir, removeDirectory } from '../helpers/test-utils.js';
|
|
5
|
+
|
|
6
|
+
describe('dynamic-directory', () => {
|
|
7
|
+
const tempDirectories: string[] = [];
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
for (const tempDirectory of tempDirectories.splice(0)) {
|
|
15
|
+
await removeDirectory(tempDirectory);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('creates missing dynamic directory', async () => {
|
|
22
|
+
const root = await createTempDirectory('fluxion-dynamic-directory-');
|
|
23
|
+
tempDirectories.push(root);
|
|
24
|
+
|
|
25
|
+
const dynamicDirectory = path.join(root, 'dynamic');
|
|
26
|
+
const existsBefore = await fs
|
|
27
|
+
.stat(dynamicDirectory)
|
|
28
|
+
.then(() => true)
|
|
29
|
+
.catch(() => false);
|
|
30
|
+
|
|
31
|
+
expect(existsBefore).toBe(false);
|
|
32
|
+
|
|
33
|
+
ensureDir(dynamicDirectory);
|
|
34
|
+
|
|
35
|
+
const stat = await fs.stat(dynamicDirectory);
|
|
36
|
+
expect(stat.isDirectory()).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does nothing when dynamic directory already exists', async () => {
|
|
40
|
+
const dynamicDirectory = await createTempDirectory('fluxion-dynamic-existing-');
|
|
41
|
+
tempDirectories.push(dynamicDirectory);
|
|
42
|
+
|
|
43
|
+
ensureDir(dynamicDirectory);
|
|
44
|
+
|
|
45
|
+
const stat = await fs.stat(dynamicDirectory);
|
|
46
|
+
expect(stat.isDirectory()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
closeServer,
|
|
8
|
+
createTempDirectory,
|
|
9
|
+
listenEphemeral,
|
|
10
|
+
removeDirectory,
|
|
11
|
+
sleep,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from '../helpers/test-utils.js';
|
|
14
|
+
import { createFileRuntime } from '@/workers/file-runtime.js';
|
|
15
|
+
import type { FileRuntime, FileRuntimeOptions } from '@/workers/file-runtime.js';
|
|
16
|
+
import { HandlerResult } from '@/common/consts.js';
|
|
17
|
+
|
|
18
|
+
async function startRuntimeServer(
|
|
19
|
+
dynamicDirectory: string,
|
|
20
|
+
options?: FileRuntimeOptions,
|
|
21
|
+
): Promise<{ server: http.Server; baseUrl: string; runtime: FileRuntime }> {
|
|
22
|
+
const runtime = createFileRuntime(dynamicDirectory, options);
|
|
23
|
+
|
|
24
|
+
const server = http.createServer((req, res) => {
|
|
25
|
+
void runtime
|
|
26
|
+
.handleRequest(req, res)
|
|
27
|
+
.then((result) => {
|
|
28
|
+
if (result === HandlerResult.NotFound) {
|
|
29
|
+
res.statusCode = 404;
|
|
30
|
+
res.end('not_found');
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.catch((error: unknown) => {
|
|
34
|
+
res.statusCode = 500;
|
|
35
|
+
res.end(String(error));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
server.once('close', () => {
|
|
40
|
+
void runtime.close();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const { baseUrl } = await listenEphemeral(server);
|
|
44
|
+
return { server, baseUrl, runtime };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('file-runtime', () => {
|
|
48
|
+
const tempDirectories: string[] = [];
|
|
49
|
+
const servers: http.Server[] = [];
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
vi.restoreAllMocks();
|
|
53
|
+
|
|
54
|
+
for (const server of servers.splice(0)) {
|
|
55
|
+
await closeServer(server);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const tempDirectory of tempDirectories.splice(0)) {
|
|
59
|
+
await removeDirectory(tempDirectory);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('prefers index.mjs over sibling .mjs handler', async () => {
|
|
64
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-priority-');
|
|
65
|
+
tempDirectories.push(dynamicDirectory);
|
|
66
|
+
|
|
67
|
+
await writeFile(
|
|
68
|
+
path.join(dynamicDirectory, 'aaa', 'bb', 'cc', 'index.mjs'),
|
|
69
|
+
"export default function handler(_req, res) { res.end('from-index'); }",
|
|
70
|
+
);
|
|
71
|
+
await writeFile(
|
|
72
|
+
path.join(dynamicDirectory, 'aaa', 'bb', 'cc.mjs'),
|
|
73
|
+
"export default function handler(_req, res) { res.end('from-file'); }",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory);
|
|
77
|
+
servers.push(server);
|
|
78
|
+
|
|
79
|
+
const response = await fetch(`${baseUrl}/aaa/bb/cc`);
|
|
80
|
+
expect(response.status).toBe(200);
|
|
81
|
+
expect(await response.text()).toBe('from-index');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reloads handler when file mtime and size change', async () => {
|
|
85
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-reload-');
|
|
86
|
+
tempDirectories.push(dynamicDirectory);
|
|
87
|
+
|
|
88
|
+
const handlerFile = path.join(dynamicDirectory, 'aaa', 'bb', 'cc.mjs');
|
|
89
|
+
|
|
90
|
+
await writeFile(handlerFile, "export default function handler(_req, res) { res.end('v1'); }");
|
|
91
|
+
|
|
92
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory);
|
|
93
|
+
servers.push(server);
|
|
94
|
+
|
|
95
|
+
const firstResponse = await fetch(`${baseUrl}/aaa/bb/cc`);
|
|
96
|
+
expect(await firstResponse.text()).toBe('v1');
|
|
97
|
+
|
|
98
|
+
await sleep(20);
|
|
99
|
+
await writeFile(handlerFile, "export default function handler(_req, res) { res.end('v2-reloaded'); }");
|
|
100
|
+
|
|
101
|
+
const secondResponse = await fetch(`${baseUrl}/aaa/bb/cc`);
|
|
102
|
+
expect(secondResponse.status).toBe(200);
|
|
103
|
+
expect(await secondResponse.text()).toBe('v2-reloaded');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('waits for callback-style async handlers to end the response', async () => {
|
|
107
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-callback-');
|
|
108
|
+
tempDirectories.push(dynamicDirectory);
|
|
109
|
+
|
|
110
|
+
await writeFile(
|
|
111
|
+
path.join(dynamicDirectory, 'delayed.mjs'),
|
|
112
|
+
"export default function handler(_req, res) { setTimeout(() => res.end('delayed-ok'), 20); }",
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory);
|
|
116
|
+
servers.push(server);
|
|
117
|
+
|
|
118
|
+
const response = await fetch(`${baseUrl}/delayed`);
|
|
119
|
+
expect(response.status).toBe(200);
|
|
120
|
+
expect(await response.text()).toBe('delayed-ok');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('serializes handler return value as json response', async () => {
|
|
124
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-return-json-');
|
|
125
|
+
tempDirectories.push(dynamicDirectory);
|
|
126
|
+
|
|
127
|
+
await writeFile(
|
|
128
|
+
path.join(dynamicDirectory, 'return-json.mjs'),
|
|
129
|
+
[
|
|
130
|
+
'export default async function handler() {',
|
|
131
|
+
' return { ok: true, count: 1 };',
|
|
132
|
+
'}',
|
|
133
|
+
].join('\n'),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory);
|
|
137
|
+
servers.push(server);
|
|
138
|
+
|
|
139
|
+
const response = await fetch(`${baseUrl}/return-json`);
|
|
140
|
+
expect(response.status).toBe(200);
|
|
141
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
142
|
+
expect((await response.json()) as Record<string, unknown>).toEqual({ ok: true, count: 1 });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('serves static .js files and blocks underscore directories', async () => {
|
|
146
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-static-');
|
|
147
|
+
tempDirectories.push(dynamicDirectory);
|
|
148
|
+
|
|
149
|
+
await writeFile(path.join(dynamicDirectory, 'assets', 'app.js'), "console.log('app');");
|
|
150
|
+
await writeFile(
|
|
151
|
+
path.join(dynamicDirectory, '_lib', 'ping.mjs'),
|
|
152
|
+
"export default function handler(_req, res) { res.end('hidden'); }",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory);
|
|
156
|
+
servers.push(server);
|
|
157
|
+
|
|
158
|
+
const staticResponse = await fetch(`${baseUrl}/assets/app.js`);
|
|
159
|
+
expect(staticResponse.status).toBe(200);
|
|
160
|
+
expect(await staticResponse.text()).toContain("console.log('app')");
|
|
161
|
+
expect(staticResponse.headers.get('content-type')).toContain('text/javascript');
|
|
162
|
+
|
|
163
|
+
const hiddenHandlerResponse = await fetch(`${baseUrl}/_lib/ping`);
|
|
164
|
+
expect(hiddenHandlerResponse.status).toBe(404);
|
|
165
|
+
expect(await hiddenHandlerResponse.text()).toBe('not_found');
|
|
166
|
+
|
|
167
|
+
const hiddenStaticResponse = await fetch(`${baseUrl}/_lib/ping.mjs`);
|
|
168
|
+
expect(hiddenStaticResponse.status).toBe(404);
|
|
169
|
+
expect(await hiddenStaticResponse.text()).toBe('not_found');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('creates route snapshot from .mjs handlers and static files', async () => {
|
|
173
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-snapshot-');
|
|
174
|
+
tempDirectories.push(dynamicDirectory);
|
|
175
|
+
|
|
176
|
+
await writeFile(
|
|
177
|
+
path.join(dynamicDirectory, 'aaa', 'bb', 'cc', 'index.mjs'),
|
|
178
|
+
"export default function handler(_req, res) { res.end('from-index'); }",
|
|
179
|
+
);
|
|
180
|
+
await writeFile(
|
|
181
|
+
path.join(dynamicDirectory, 'aaa', 'bb', 'cc.mjs'),
|
|
182
|
+
"export default function handler(_req, res) { res.end('from-file'); }",
|
|
183
|
+
);
|
|
184
|
+
await writeFile(path.join(dynamicDirectory, 'public', 'app.js'), "console.log('app');");
|
|
185
|
+
await writeFile(path.join(dynamicDirectory, '_lib', 'internal.mjs'), 'export default () => {};');
|
|
186
|
+
|
|
187
|
+
const runtime = createFileRuntime(dynamicDirectory);
|
|
188
|
+
const snapshot = await runtime.getRouteSnapshot();
|
|
189
|
+
|
|
190
|
+
expect(snapshot.handlers).toEqual([
|
|
191
|
+
{
|
|
192
|
+
route: '/aaa/bb/cc',
|
|
193
|
+
file: 'aaa/bb/cc/index.mjs',
|
|
194
|
+
version: expect.stringContaining(':'),
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
expect(snapshot.staticFiles).toContainEqual({
|
|
199
|
+
route: '/public/app.js',
|
|
200
|
+
file: 'public/app.js',
|
|
201
|
+
version: expect.stringContaining(':'),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(snapshot.handlers.some((item) => item.file.includes('_lib'))).toBe(false);
|
|
205
|
+
expect(snapshot.staticFiles.some((item) => item.file.includes('_lib'))).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('supports object-style default export and passes handler context', async () => {
|
|
209
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-context-');
|
|
210
|
+
tempDirectories.push(dynamicDirectory);
|
|
211
|
+
|
|
212
|
+
await writeFile(
|
|
213
|
+
path.join(dynamicDirectory, 'ctx.mjs'),
|
|
214
|
+
[
|
|
215
|
+
'export default {',
|
|
216
|
+
' handler(_req, res, context) {',
|
|
217
|
+
" res.setHeader('x-worker-id', context.worker.id);",
|
|
218
|
+
" res.end(typeof context.worker?.id === 'string' ? 'context-ok' : 'context-missing');",
|
|
219
|
+
' },',
|
|
220
|
+
'};',
|
|
221
|
+
].join('\n'),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory);
|
|
225
|
+
servers.push(server);
|
|
226
|
+
|
|
227
|
+
const response = await fetch(`${baseUrl}/ctx`);
|
|
228
|
+
expect(response.status).toBe(200);
|
|
229
|
+
expect(await response.text()).toBe('context-ok');
|
|
230
|
+
expect(response.headers.get('x-worker-id')).toContain('fluxion-worker-all');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('injects module instances into context via modules declarations', async () => {
|
|
234
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-db-config-');
|
|
235
|
+
tempDirectories.push(dynamicDirectory);
|
|
236
|
+
|
|
237
|
+
await writeFile(
|
|
238
|
+
path.join(dynamicDirectory, 'dbctx.mjs'),
|
|
239
|
+
[
|
|
240
|
+
'export default {',
|
|
241
|
+
' modules: [',
|
|
242
|
+
' {',
|
|
243
|
+
" module: 'node:crypto',",
|
|
244
|
+
" injectKey: 'mydb',",
|
|
245
|
+
' factory: (cryptoModule) => {',
|
|
246
|
+
' return {',
|
|
247
|
+
' query(sql) {',
|
|
248
|
+
" return cryptoModule.createHash('sha1').update(String(sql)).digest('hex');",
|
|
249
|
+
' },',
|
|
250
|
+
' };',
|
|
251
|
+
' },',
|
|
252
|
+
' },',
|
|
253
|
+
' ],',
|
|
254
|
+
' handler(_req, res, context) {',
|
|
255
|
+
" const ready = typeof context.mydb?.query === 'function';",
|
|
256
|
+
" const sample = ready ? context.mydb.query('select 1') : '';",
|
|
257
|
+
" res.setHeader('x-ready', String(ready));",
|
|
258
|
+
' res.end(ready && sample.length > 0 ? "module-connected" : "module-missing");',
|
|
259
|
+
' },',
|
|
260
|
+
'};',
|
|
261
|
+
].join('\n'),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory);
|
|
265
|
+
servers.push(server);
|
|
266
|
+
|
|
267
|
+
const response = await fetch(`${baseUrl}/dbctx`);
|
|
268
|
+
expect(response.status).toBe(200);
|
|
269
|
+
expect(await response.text()).toBe('module-connected');
|
|
270
|
+
expect(response.headers.get('x-ready')).toBe('true');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('rejects legacy handler-level db declaration', async () => {
|
|
274
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-db-shape-');
|
|
275
|
+
tempDirectories.push(dynamicDirectory);
|
|
276
|
+
|
|
277
|
+
await writeFile(
|
|
278
|
+
path.join(dynamicDirectory, 'invalid-db-shape.mjs'),
|
|
279
|
+
[
|
|
280
|
+
'export default {',
|
|
281
|
+
" db: ['main'],",
|
|
282
|
+
' handler(_req, res) {',
|
|
283
|
+
" res.end('never');",
|
|
284
|
+
' },',
|
|
285
|
+
'};',
|
|
286
|
+
].join('\n'),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory);
|
|
290
|
+
servers.push(server);
|
|
291
|
+
|
|
292
|
+
const response = await fetch(`${baseUrl}/invalid-db-shape`);
|
|
293
|
+
expect(response.status).toBe(500);
|
|
294
|
+
expect(await response.text()).toContain('Legacy db declaration is no longer supported');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('rejects modules declaration when injectKey is reserved', async () => {
|
|
298
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-module-reserved-');
|
|
299
|
+
tempDirectories.push(dynamicDirectory);
|
|
300
|
+
|
|
301
|
+
await writeFile(
|
|
302
|
+
path.join(dynamicDirectory, 'invalid-inject-key.mjs'),
|
|
303
|
+
[
|
|
304
|
+
'export default {',
|
|
305
|
+
' modules: [',
|
|
306
|
+
' {',
|
|
307
|
+
" module: 'node:crypto',",
|
|
308
|
+
" injectKey: 'worker',",
|
|
309
|
+
' factory: () => ({})',
|
|
310
|
+
' },',
|
|
311
|
+
' ],',
|
|
312
|
+
' handler(_req, res) {',
|
|
313
|
+
" res.end('never');",
|
|
314
|
+
' },',
|
|
315
|
+
'};',
|
|
316
|
+
].join('\n'),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory);
|
|
320
|
+
servers.push(server);
|
|
321
|
+
|
|
322
|
+
const response = await fetch(`${baseUrl}/invalid-inject-key`);
|
|
323
|
+
expect(response.status).toBe(500);
|
|
324
|
+
expect(await response.text()).toContain('injectKey "worker" is reserved');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('fails request when worker response exceeds maxResponseBytes', async () => {
|
|
328
|
+
const dynamicDirectory = await createTempDirectory('fluxion-runtime-res-size-');
|
|
329
|
+
tempDirectories.push(dynamicDirectory);
|
|
330
|
+
|
|
331
|
+
await writeFile(
|
|
332
|
+
path.join(dynamicDirectory, 'large.mjs'),
|
|
333
|
+
"export default function handler(_req, res) { res.end('0123456789'.repeat(40)); }",
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const { server, baseUrl } = await startRuntimeServer(dynamicDirectory, {
|
|
337
|
+
workerOptions: {
|
|
338
|
+
maxResponseBytes: 128,
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
servers.push(server);
|
|
342
|
+
|
|
343
|
+
const response = await fetch(`${baseUrl}/large`);
|
|
344
|
+
expect(response.status).toBe(500);
|
|
345
|
+
expect(await response.text()).toContain('worker response too large');
|
|
346
|
+
});
|
|
347
|
+
});
|