-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhost.ts
More file actions
159 lines (141 loc) · 5.07 KB
/
host.ts
File metadata and controls
159 lines (141 loc) · 5.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
/**
* @file Chrome native messaging host entry point. Chrome launches this script
* as a subprocess when the extension calls
* `chrome.runtime.connectNative('dev.socket.trusted-publisher-host')`. The
* protocol is length-prefixed binary over stdin/stdout: incoming: [4-byte LE
* uint32 length][UTF-8 JSON message] outgoing: [4-byte LE uint32
* length][UTF-8 JSON response] The host handles one request type: { type:
* 'get-api-token' } → { token: string } | { error: string } The host NEVER
* logs to stdout (Chrome treats any stdout byte outside the length-prefixed
* protocol as a message boundary error). All diagnostics go to stderr only.
* Detection: Chrome passes the extension origin as `process.argv[2]`
* (`chrome-extension://<id>/`). The `NATIVE_MESSAGING_HOST` constant in
* `src/constants/platform.ts` captures this check so other modules can skip
* TTY-only paths when running in this context.
*/
import process from 'node:process'
import { errorMessage } from '../errors/message'
import { getDefaultLogger } from '../logger/default'
import { ErrorCtor } from '../primordials/error'
import { readSocketApiToken } from '../secrets/socket-api-token'
import { assertNodeStripTypesSupported } from './install'
import type { Readable, Writable } from 'node:stream'
const logger = getDefaultLogger()
export async function handleOne(
stdin?: Readable,
stdout?: Writable,
): Promise<void> {
const inStream = stdin ?? process.stdin
const outStream = stdout ?? process.stdout
const header = await readExact(4, inStream)
const length = header.readUInt32LE(0)
if (length === 0 || length > 1_048_576) {
writeMessage({ error: `invalid message length: ${length}` }, outStream)
return
}
const body = await readExact(length, inStream)
let msg: unknown
try {
msg = JSON.parse(body.toString('utf8'))
} catch {
writeMessage({ error: 'message is not valid JSON' }, outStream)
return
}
const type = (msg as Record<string, unknown>)['type']
if (type === 'get-api-token') {
const token = await readSocketApiToken()
if (token) {
writeMessage({ token }, outStream)
} else {
writeMessage(
{
error:
'Socket API token not found. Set SOCKET_API_TOKEN in your environment.',
},
outStream,
)
}
return
}
writeMessage({ error: `unknown message type: ${String(type)}` }, outStream)
}
// Native messaging: read exactly `length` bytes from the stream. Uses
// the paused-mode `readable` event so leftover bytes stay in the
// stream's internal buffer between calls — flowing-mode `data` would
// hand us oversized chunks with no way to put the tail back.
export function readExact(length: number, stream?: Readable): Promise<Buffer> {
const src = stream ?? process.stdin
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let received = 0
function cleanup(): void {
src.off('readable', onReadable)
src.off('error', onError)
src.off('end', onEnd)
}
function tryRead(): void {
let needed = length - received
while (needed > 0) {
const chunk = src.read(needed) ?? src.read()
if (chunk === null) {
return
}
chunks.push(chunk)
received += chunk.length
needed = length - received
}
cleanup()
const full = Buffer.concat(chunks)
if (received > length) {
src.unshift(full.subarray(length))
}
resolve(full.subarray(0, length))
}
function onReadable(): void {
tryRead()
}
function onError(err: Error): void {
cleanup()
reject(err)
}
function onEnd(): void {
cleanup()
reject(new ErrorCtor('stdin closed before message was complete'))
}
src.on('readable', onReadable)
src.on('error', onError)
src.once('end', onEnd)
// Drain anything already buffered (e.g. from a previous readExact
// unshift, or a synchronously-populated test stream).
tryRead()
})
}
export async function runHost(): Promise<void> {
// Defense in depth: the installer already gates on Node version, but a
// user could switch Node versions (e.g. via nvm) between install and the
// moment Chrome execs the wrapper. logger.error writes to stderr —
// stdout is reserved for the length-prefixed NM protocol.
try {
assertNodeStripTypesSupported()
} catch (e) {
logger.error(errorMessage(e))
process.exit(1)
}
while (true) {
try {
await handleOne()
} catch {
// stdin closed — normal Chrome shutdown.
process.exit(0)
}
}
}
export function writeMessage(obj: unknown, stream?: Writable): void {
const payload = Buffer.from(JSON.stringify(obj), 'utf8')
const header = Buffer.allocUnsafe(4)
header.writeUInt32LE(payload.length, 0)
// Native messaging protocol requires raw binary writes to stdout.
// Chrome treats any non-protocol byte as a framing error, so the logger
// must not be used here. socket-lint: allow process-stdio
;(stream ?? process.stdout).write(Buffer.concat([header, payload])) // socket-lint: allow process-stdio
}