odac 1.4.15 → 1.4.17

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/client/odac.js +38 -1
  3. package/docs/ai/skills/backend/forms.md +54 -3
  4. package/docs/ai/skills/backend/validation.md +30 -2
  5. package/docs/backend/05-forms/01-custom-forms.md +52 -0
  6. package/docs/backend/09-validation/01-the-validator-service.md +40 -0
  7. package/docs/backend/13-utilities/02-ipc.md +26 -1
  8. package/docs/frontend/03-forms/01-form-handling.md +30 -13
  9. package/package.json +2 -1
  10. package/src/Auth.js +252 -24
  11. package/src/Config.js +5 -1
  12. package/src/Ipc.js +87 -14
  13. package/src/Odac.js +23 -4
  14. package/src/Request.js +235 -34
  15. package/src/Route/Internal.js +32 -3
  16. package/src/Validator.js +143 -2
  17. package/src/View/Form.js +56 -0
  18. package/src/View/Image.js +15 -6
  19. package/src/View.js +2 -2
  20. package/src/WebSocket.js +20 -1
  21. package/test/Auth/check.test.js +219 -18
  22. package/test/Auth/verifyMagicLink.test.js +8 -1
  23. package/test/Ipc/subscribe.test.js +154 -0
  24. package/test/Ipc/subscribeRedis.test.js +141 -0
  25. package/test/Odac/cache.test.js +5 -2
  26. package/test/Odac/image.test.js +8 -5
  27. package/test/Odac/instance.test.js +5 -2
  28. package/test/Request/cache.test.js +1 -0
  29. package/test/Request/multipart.test.js +164 -0
  30. package/test/Validator/file.test.js +172 -0
  31. package/test/View/Form/generateFieldHtml.test.js +56 -0
  32. package/test/View/Image/serve.test.js +9 -4
  33. package/test/View/Image/url.test.js +10 -4
  34. package/test/View/addNavigateAttribute.test.js +10 -1
  35. package/test/View/print.test.js +10 -1
  36. package/test/WebSocket/Client/fragmentation.test.js +24 -5
  37. package/test/WebSocket/Client/limits.test.js +21 -2
  38. package/test/WebSocket/Client/readyState.test.js +29 -10
  39. package/test/WebSocket/Client/send.test.js +148 -0
@@ -1,21 +1,26 @@
1
1
  const fs = require('fs')
2
2
  const fsPromises = fs.promises
3
+ const os = require('os')
3
4
  const path = require('path')
4
5
  const Image = require('../../../src/View/Image')
5
6
 
6
- const IMG_CACHE_DIR = './storage/.cache/img'
7
-
8
7
  describe('Image.serve()', () => {
9
8
  const testFilename = 'testserve1234567.webp'
10
- const testFilePath = path.join(IMG_CACHE_DIR, testFilename)
9
+ let tmpDir, IMG_CACHE_DIR, testFilePath
11
10
 
12
11
  beforeAll(async () => {
12
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-image-serve-'))
13
+ global.__dir = tmpDir
14
+ IMG_CACHE_DIR = path.join(tmpDir, 'storage/.cache/img')
15
+ testFilePath = path.join(IMG_CACHE_DIR, testFilename)
16
+
13
17
  await fsPromises.mkdir(IMG_CACHE_DIR, {recursive: true})
14
18
  await fsPromises.writeFile(testFilePath, Buffer.from('fake-image-data'))
15
19
  })
16
20
 
17
21
  afterAll(async () => {
18
- await fsPromises.unlink(testFilePath).catch(() => {})
22
+ delete global.__dir
23
+ await fsPromises.rm(tmpDir, {recursive: true, force: true})
19
24
  })
20
25
 
21
26
  test('should return stream, type, and size for a cached file', async () => {
@@ -1,20 +1,26 @@
1
1
  const fs = require('fs')
2
2
  const fsPromises = fs.promises
3
+ const os = require('os')
3
4
  const path = require('path')
4
5
  const Image = require('../../../src/View/Image')
5
6
 
6
7
  describe('Image.url()', () => {
7
- const publicDir = path.join(process.cwd(), 'public')
8
- const testImageDir = path.join(publicDir, 'images')
9
- const testImagePath = path.join(testImageDir, 'url-test.jpg')
8
+ let tmpDir, testImagePath
10
9
 
11
10
  beforeAll(async () => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-image-url-'))
12
+ global.__dir = tmpDir
13
+
14
+ const testImageDir = path.join(tmpDir, 'public', 'images')
15
+ testImagePath = path.join(testImageDir, 'url-test.jpg')
16
+
12
17
  await fsPromises.mkdir(testImageDir, {recursive: true})
13
18
  await fsPromises.writeFile(testImagePath, Buffer.from('fake-jpg-data'))
14
19
  })
15
20
 
16
21
  afterAll(async () => {
17
- await fsPromises.unlink(testImagePath).catch(() => {})
22
+ delete global.__dir
23
+ await fsPromises.rm(tmpDir, {recursive: true, force: true})
18
24
  })
19
25
 
20
26
  test('should return empty string for empty src', async () => {
@@ -1,13 +1,20 @@
1
1
  const fs = require('fs').promises
2
+ const fsSync = require('fs')
3
+ const os = require('os')
2
4
  const path = require('path')
3
5
  const View = require('../../src/View')
4
6
 
5
7
  describe('View.#addNavigateAttribute()', () => {
6
8
  let view
7
9
  let mockOdac
10
+ let tmpDir
11
+ let originalCwd
8
12
 
9
13
  beforeEach(() => {
10
- global.__dir = path.resolve(__dirname, '../../')
14
+ tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'odac-view-navattr-'))
15
+ global.__dir = tmpDir
16
+ originalCwd = process.cwd()
17
+ process.chdir(tmpDir)
11
18
  mockOdac = {
12
19
  Config: {view: {earlyHints: {enabled: false}}},
13
20
  View: {},
@@ -28,6 +35,8 @@ describe('View.#addNavigateAttribute()', () => {
28
35
  afterEach(() => {
29
36
  jest.restoreAllMocks()
30
37
  delete global.__dir
38
+ process.chdir(originalCwd)
39
+ fsSync.rmSync(tmpDir, {recursive: true, force: true})
31
40
  })
32
41
 
33
42
  it('should not wrap placeholder in display:contents if it is the first child of an element', async () => {
@@ -1,13 +1,20 @@
1
1
  const fs = require('fs').promises
2
+ const fsSync = require('fs')
3
+ const os = require('os')
2
4
  const path = require('path')
3
5
  const View = require('../../src/View')
4
6
 
5
7
  describe('View.print()', () => {
6
8
  let view
7
9
  let mockOdac
10
+ let tmpDir
11
+ let originalCwd
8
12
 
9
13
  beforeEach(() => {
10
- global.__dir = path.resolve(__dirname, '../../')
14
+ tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'odac-view-print-'))
15
+ global.__dir = tmpDir
16
+ originalCwd = process.cwd()
17
+ process.chdir(tmpDir)
11
18
  mockOdac = {
12
19
  Config: {view: {earlyHints: {enabled: false}}},
13
20
  View: {},
@@ -28,6 +35,8 @@ describe('View.print()', () => {
28
35
  afterEach(() => {
29
36
  jest.restoreAllMocks()
30
37
  delete global.__dir
38
+ process.chdir(originalCwd)
39
+ fsSync.rmSync(tmpDir, {recursive: true, force: true})
31
40
  })
32
41
 
33
42
  it('should be a function', () => {
@@ -31,6 +31,25 @@ function createMockSocket() {
31
31
  }
32
32
  }
33
33
 
34
+ const openClients = []
35
+
36
+ /**
37
+ * Creates a client and registers it for teardown.
38
+ *
39
+ * The constructor starts a rate-limit interval that is only cleared on close(),
40
+ * so a client left open keeps Jest's event loop alive after the run finishes.
41
+ */
42
+ function createClient(...args) {
43
+ const client = new WebSocketClient(...args)
44
+ openClients.push(client)
45
+ return client
46
+ }
47
+
48
+ afterEach(() => {
49
+ for (const client of openClients) client.close()
50
+ openClients.length = 0
51
+ })
52
+
34
53
  describe('WebSocketClient Fragmentation', () => {
35
54
  let server
36
55
 
@@ -40,7 +59,7 @@ describe('WebSocketClient Fragmentation', () => {
40
59
 
41
60
  it('should reassemble fragmented text messages', () => {
42
61
  const socket = createMockSocket()
43
- const client = new WebSocketClient(socket, server, 'frag-1')
62
+ const client = createClient(socket, server, 'frag-1')
44
63
  client.resume()
45
64
 
46
65
  const messages = []
@@ -60,7 +79,7 @@ describe('WebSocketClient Fragmentation', () => {
60
79
 
61
80
  it('should reassemble fragmented binary messages', () => {
62
81
  const socket = createMockSocket()
63
- const client = new WebSocketClient(socket, server, 'frag-2')
82
+ const client = createClient(socket, server, 'frag-2')
64
83
  client.resume()
65
84
 
66
85
  const messages = []
@@ -83,7 +102,7 @@ describe('WebSocketClient Fragmentation', () => {
83
102
 
84
103
  it('should close with 1002 on unexpected continuation frame', () => {
85
104
  const socket = createMockSocket()
86
- const client = new WebSocketClient(socket, server, 'frag-3')
105
+ const client = createClient(socket, server, 'frag-3')
87
106
  client.resume()
88
107
 
89
108
  const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
@@ -96,7 +115,7 @@ describe('WebSocketClient Fragmentation', () => {
96
115
 
97
116
  it('should handle single unfragmented message normally', () => {
98
117
  const socket = createMockSocket()
99
- const client = new WebSocketClient(socket, server, 'frag-4')
118
+ const client = createClient(socket, server, 'frag-4')
100
119
  client.resume()
101
120
 
102
121
  const messages = []
@@ -112,7 +131,7 @@ describe('WebSocketClient Fragmentation', () => {
112
131
 
113
132
  it('should discard fragment buffer on close', () => {
114
133
  const socket = createMockSocket()
115
- const client = new WebSocketClient(socket, server, 'frag-5')
134
+ const client = createClient(socket, server, 'frag-5')
116
135
  client.resume()
117
136
 
118
137
  const messages = []
@@ -1,5 +1,24 @@
1
1
  const {WebSocketServer, WebSocketClient} = require('../../../src/WebSocket.js')
2
2
 
3
+ const openClients = []
4
+
5
+ /**
6
+ * Creates a client and registers it for teardown.
7
+ *
8
+ * The constructor starts a rate-limit interval that is only cleared on close(),
9
+ * so a client left open keeps Jest's event loop alive after the run finishes.
10
+ */
11
+ function createClient(...args) {
12
+ const client = new WebSocketClient(...args)
13
+ openClients.push(client)
14
+ return client
15
+ }
16
+
17
+ afterEach(() => {
18
+ for (const client of openClients) client.close()
19
+ openClients.length = 0
20
+ })
21
+
3
22
  describe('WebSocketClient Limits', () => {
4
23
  let server
5
24
 
@@ -18,7 +37,7 @@ describe('WebSocketClient Limits', () => {
18
37
  removeAllListeners: jest.fn(),
19
38
  writable: true
20
39
  }
21
- const client = new WebSocketClient(socket, server, 'test-id', {maxPayload: 10})
40
+ const client = createClient(socket, server, 'test-id', {maxPayload: 10})
22
41
  client.resume()
23
42
 
24
43
  const buffer = Buffer.alloc(100)
@@ -42,7 +61,7 @@ describe('WebSocketClient Limits', () => {
42
61
  removeAllListeners: jest.fn(),
43
62
  writable: true
44
63
  }
45
- const client = new WebSocketClient(socket, server, 'test-id', {rateLimit: {max: 2, window: 1000}})
64
+ const client = createClient(socket, server, 'test-id', {rateLimit: {max: 2, window: 1000}})
46
65
  client.resume()
47
66
 
48
67
  const buffer = Buffer.alloc(7)
@@ -16,6 +16,25 @@ function createMockSocket() {
16
16
  }
17
17
  }
18
18
 
19
+ const openClients = []
20
+
21
+ /**
22
+ * Creates a client and registers it for teardown.
23
+ *
24
+ * The constructor starts a rate-limit interval that is only cleared on close(),
25
+ * so a client left open keeps Jest's event loop alive after the run finishes.
26
+ */
27
+ function createClient(...args) {
28
+ const client = new WebSocketClient(...args)
29
+ openClients.push(client)
30
+ return client
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const client of openClients) client.close()
35
+ openClients.length = 0
36
+ })
37
+
19
38
  describe('WebSocketClient readyState', () => {
20
39
  let server
21
40
 
@@ -41,13 +60,13 @@ describe('WebSocketClient readyState', () => {
41
60
 
42
61
  it('should start in CONNECTING state', () => {
43
62
  const socket = createMockSocket()
44
- const client = new WebSocketClient(socket, server, 'rs-1')
63
+ const client = createClient(socket, server, 'rs-1')
45
64
  expect(client.readyState).toBe(READY_STATE.CONNECTING)
46
65
  })
47
66
 
48
67
  it('should transition to OPEN on resume()', () => {
49
68
  const socket = createMockSocket()
50
- const client = new WebSocketClient(socket, server, 'rs-2')
69
+ const client = createClient(socket, server, 'rs-2')
51
70
 
52
71
  client.resume()
53
72
 
@@ -57,7 +76,7 @@ describe('WebSocketClient readyState', () => {
57
76
 
58
77
  it('should transition to CLOSED after close()', () => {
59
78
  const socket = createMockSocket()
60
- const client = new WebSocketClient(socket, server, 'rs-3')
79
+ const client = createClient(socket, server, 'rs-3')
61
80
  client.resume()
62
81
 
63
82
  client.close()
@@ -68,7 +87,7 @@ describe('WebSocketClient readyState', () => {
68
87
 
69
88
  it('should be idempotent — second close() is a no-op', () => {
70
89
  const socket = createMockSocket()
71
- const client = new WebSocketClient(socket, server, 'rs-4')
90
+ const client = createClient(socket, server, 'rs-4')
72
91
  client.resume()
73
92
 
74
93
  client.close()
@@ -79,7 +98,7 @@ describe('WebSocketClient readyState', () => {
79
98
 
80
99
  it('should not send data when in CONNECTING state', () => {
81
100
  const socket = createMockSocket()
82
- const client = new WebSocketClient(socket, server, 'rs-5')
101
+ const client = createClient(socket, server, 'rs-5')
83
102
 
84
103
  client.send('hello')
85
104
 
@@ -88,7 +107,7 @@ describe('WebSocketClient readyState', () => {
88
107
 
89
108
  it('should not send data when in CLOSED state', () => {
90
109
  const socket = createMockSocket()
91
- const client = new WebSocketClient(socket, server, 'rs-6')
110
+ const client = createClient(socket, server, 'rs-6')
92
111
  client.resume()
93
112
  client.close()
94
113
 
@@ -103,7 +122,7 @@ describe('WebSocketClient readyState', () => {
103
122
 
104
123
  it('should not send ping when not OPEN', () => {
105
124
  const socket = createMockSocket()
106
- const client = new WebSocketClient(socket, server, 'rs-7')
125
+ const client = createClient(socket, server, 'rs-7')
107
126
 
108
127
  client.ping()
109
128
 
@@ -113,7 +132,7 @@ describe('WebSocketClient readyState', () => {
113
132
  it('should not write when socket is not writable', () => {
114
133
  const socket = createMockSocket()
115
134
  socket.writable = false
116
- const client = new WebSocketClient(socket, server, 'rs-8')
135
+ const client = createClient(socket, server, 'rs-8')
117
136
  client.resume()
118
137
 
119
138
  client.send('hello')
@@ -123,7 +142,7 @@ describe('WebSocketClient readyState', () => {
123
142
 
124
143
  it('should transition to CLOSED when socket fires close event', () => {
125
144
  const socket = createMockSocket()
126
- const client = new WebSocketClient(socket, server, 'rs-9')
145
+ const client = createClient(socket, server, 'rs-9')
127
146
  server.clients.set('rs-9', client)
128
147
  client.resume()
129
148
 
@@ -136,7 +155,7 @@ describe('WebSocketClient readyState', () => {
136
155
 
137
156
  it('should emit close event only once on double cleanup', () => {
138
157
  const socket = createMockSocket()
139
- const client = new WebSocketClient(socket, server, 'rs-10')
158
+ const client = createClient(socket, server, 'rs-10')
140
159
  server.clients.set('rs-10', client)
141
160
  client.resume()
142
161
 
@@ -0,0 +1,148 @@
1
+ const {WebSocketServer, WebSocketClient} = require('../../../src/WebSocket.js')
2
+
3
+ function createMockSocket() {
4
+ return {
5
+ pause: jest.fn(),
6
+ resume: jest.fn(),
7
+ on: jest.fn(),
8
+ write: jest.fn(),
9
+ end: jest.fn(),
10
+ removeAllListeners: jest.fn(),
11
+ writable: true
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Splits the frame handed to socket.write() back into opcode and payload.
17
+ * Server-sent frames are never masked, so the payload starts right after
18
+ * the (possibly extended) length field.
19
+ */
20
+ function readFrame(socket) {
21
+ const frame = socket.write.mock.calls[0][0]
22
+ const opcode = frame[0] & 0x0f
23
+ const length = frame[1] & 0x7f
24
+
25
+ let offset = 2
26
+ if (length === 126) offset = 4
27
+ else if (length === 127) offset = 10
28
+
29
+ return {opcode, payload: frame.subarray(offset)}
30
+ }
31
+
32
+ const openClients = []
33
+
34
+ /**
35
+ * Creates a client and registers it for teardown.
36
+ *
37
+ * The constructor starts a rate-limit interval that is only cleared on close(),
38
+ * so a client left open keeps Jest's event loop alive after the run finishes.
39
+ */
40
+ function createClient(...args) {
41
+ const client = new WebSocketClient(...args)
42
+ openClients.push(client)
43
+ return client
44
+ }
45
+
46
+ afterEach(() => {
47
+ for (const client of openClients) client.close()
48
+ openClients.length = 0
49
+ })
50
+
51
+ describe('WebSocketClient send', () => {
52
+ let server
53
+ let client
54
+ let socket
55
+
56
+ beforeEach(() => {
57
+ server = new WebSocketServer()
58
+ socket = createMockSocket()
59
+ client = createClient(socket, server, 'send-1')
60
+ client.resume()
61
+ })
62
+
63
+ it('should send strings as TEXT frames', () => {
64
+ client.send('hello')
65
+
66
+ const {opcode, payload} = readFrame(socket)
67
+
68
+ expect(opcode).toBe(0x1)
69
+ expect(payload.toString('utf8')).toBe('hello')
70
+ })
71
+
72
+ it('should send plain objects as JSON TEXT frames', () => {
73
+ client.send({type: 'ping', n: 1})
74
+
75
+ const {opcode, payload} = readFrame(socket)
76
+
77
+ expect(opcode).toBe(0x1)
78
+ expect(JSON.parse(payload.toString('utf8'))).toEqual({type: 'ping', n: 1})
79
+ })
80
+
81
+ it('should send a Buffer as a BINARY frame instead of JSON', () => {
82
+ client.send(Buffer.from([0xde, 0xad, 0xbe, 0xef]))
83
+
84
+ const {opcode, payload} = readFrame(socket)
85
+
86
+ expect(opcode).toBe(0x2)
87
+ expect([...payload]).toEqual([0xde, 0xad, 0xbe, 0xef])
88
+ })
89
+
90
+ it('should send a TypedArray as a BINARY frame', () => {
91
+ client.send(new Uint8Array([1, 2, 3]))
92
+
93
+ const {opcode, payload} = readFrame(socket)
94
+
95
+ expect(opcode).toBe(0x2)
96
+ expect([...payload]).toEqual([1, 2, 3])
97
+ })
98
+
99
+ it('should send an ArrayBuffer as a BINARY frame', () => {
100
+ const view = new Uint8Array([9, 8, 7])
101
+ client.send(view.buffer)
102
+
103
+ const {opcode, payload} = readFrame(socket)
104
+
105
+ expect(opcode).toBe(0x2)
106
+ expect([...payload]).toEqual([9, 8, 7])
107
+ })
108
+
109
+ it('should preserve the bytes of multi-byte TypedArrays', () => {
110
+ // Buffer.from(view) would read each element as a number and truncate it
111
+ // to one byte, silently corrupting anything wider than a Uint8Array.
112
+ client.send(new Uint16Array([0x0100, 0x0200]))
113
+
114
+ const {opcode, payload} = readFrame(socket)
115
+
116
+ expect(opcode).toBe(0x2)
117
+ expect(payload.length).toBe(4)
118
+ expect([...payload]).toEqual([0x00, 0x01, 0x00, 0x02])
119
+ })
120
+
121
+ it('should honour the byteOffset of a view over a larger buffer', () => {
122
+ const backing = new Uint8Array([1, 2, 3, 4, 5, 6])
123
+ const view = backing.subarray(2, 5)
124
+
125
+ client.send(view)
126
+
127
+ const {payload} = readFrame(socket)
128
+
129
+ expect([...payload]).toEqual([3, 4, 5])
130
+ })
131
+
132
+ it('should send binary through sendBinary without corrupting wide views', () => {
133
+ client.sendBinary(new Uint16Array([0x0100]))
134
+
135
+ const {opcode, payload} = readFrame(socket)
136
+
137
+ expect(opcode).toBe(0x2)
138
+ expect([...payload]).toEqual([0x00, 0x01])
139
+ })
140
+
141
+ it('should not send when the connection is not open', () => {
142
+ const closed = createClient(createMockSocket(), server, 'send-2')
143
+
144
+ closed.send('hello')
145
+
146
+ expect(closed.readyState).toBe(WebSocketClient.CONNECTING)
147
+ })
148
+ })