@expandai/sdk 0.20.1 → 0.20.2
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/README.md +11 -0
- package/build/dts/Error.d.ts +25 -1
- package/build/dts/Error.d.ts.map +1 -1
- package/build/dts/Internal.d.ts.map +1 -1
- package/build/dts/RetryAfter.d.ts +2 -0
- package/build/dts/RetryAfter.d.ts.map +1 -1
- package/build/dts/Service.d.ts +9 -2
- package/build/dts/Service.d.ts.map +1 -1
- package/build/dts/Tunnel/Headers.d.ts +2 -0
- package/build/dts/Tunnel/Headers.d.ts.map +1 -0
- package/build/dts/Tunnel/LocalTarget.d.ts +122 -0
- package/build/dts/Tunnel/LocalTarget.d.ts.map +1 -0
- package/build/dts/Tunnel/Protocol.d.ts +160 -0
- package/build/dts/Tunnel/Protocol.d.ts.map +1 -0
- package/build/dts/Tunnel/StaticServer.d.ts +10 -0
- package/build/dts/Tunnel/StaticServer.d.ts.map +1 -0
- package/build/dts/Tunnel/Tunnel.d.ts +40 -0
- package/build/dts/Tunnel/Tunnel.d.ts.map +1 -0
- package/build/dts/index.d.ts +2 -2
- package/build/dts/index.d.ts.map +1 -1
- package/build/esm/Error.js +27 -0
- package/build/esm/Error.js.map +1 -1
- package/build/esm/Internal.js +9 -1
- package/build/esm/Internal.js.map +1 -1
- package/build/esm/RetryAfter.js +3 -1
- package/build/esm/RetryAfter.js.map +1 -1
- package/build/esm/Service.js +23 -7
- package/build/esm/Service.js.map +1 -1
- package/build/esm/Tunnel/Headers.js +18 -0
- package/build/esm/Tunnel/Headers.js.map +1 -0
- package/build/esm/Tunnel/LocalTarget.js +125 -0
- package/build/esm/Tunnel/LocalTarget.js.map +1 -0
- package/build/esm/Tunnel/Protocol.js +73 -0
- package/build/esm/Tunnel/Protocol.js.map +1 -0
- package/build/esm/Tunnel/StaticServer.js +69 -0
- package/build/esm/Tunnel/StaticServer.js.map +1 -0
- package/build/esm/Tunnel/Tunnel.js +302 -0
- package/build/esm/Tunnel/Tunnel.js.map +1 -0
- package/build/esm/index.js +1 -1
- package/build/esm/index.js.map +1 -1
- package/package.json +6 -3
- package/src/Error.ts +53 -1
- package/src/Internal.ts +10 -0
- package/src/RetryAfter.ts +4 -1
- package/src/Service.ts +48 -8
- package/src/Tunnel/Headers.ts +22 -0
- package/src/Tunnel/LocalTarget.ts +153 -0
- package/src/Tunnel/Protocol.ts +99 -0
- package/src/Tunnel/StaticServer.ts +85 -0
- package/src/Tunnel/Tunnel.ts +420 -0
- package/src/index.ts +3 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { FileSystem, Path } from '@effect/platform'
|
|
3
|
+
import type { PlatformError } from '@effect/platform/Error'
|
|
4
|
+
import { Data, Effect, Match, Option } from 'effect'
|
|
5
|
+
import { ExpandLocalTargetError } from '../Error.js'
|
|
6
|
+
|
|
7
|
+
/** Something on this machine the Expand API cannot reach without a tunnel. */
|
|
8
|
+
export type LocalTarget = Data.TaggedEnum<{
|
|
9
|
+
/** A server already listening on a loopback port. `host` is dialled as written; `path` includes the query and fragment for browser navigation. */
|
|
10
|
+
Port: { readonly host: string; readonly port: number; readonly path: string }
|
|
11
|
+
File: { readonly path: string }
|
|
12
|
+
Directory: { readonly path: string }
|
|
13
|
+
}>
|
|
14
|
+
export const LocalTarget = Data.taggedEnum<LocalTarget>()
|
|
15
|
+
|
|
16
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0'])
|
|
17
|
+
const LOOPBACK_PREFIX = /^(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(?=[:/?#]|$)/iu
|
|
18
|
+
|
|
19
|
+
function parseUrl(input: string): Option.Option<URL> {
|
|
20
|
+
// A schemeless `localhost:3000/x` is plainly local, and only ever a plaintext dev server.
|
|
21
|
+
const candidate = LOOPBACK_PREFIX.test(input) ? `http://${input}` : input
|
|
22
|
+
return URL.canParse(candidate) ? Option.some(new URL(candidate)) : Option.none()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// TLS dev servers are not tunnelled: the client dials plaintext, so `https://localhost` would only
|
|
26
|
+
// ever get a connection reset.
|
|
27
|
+
function isHttpLoopback(url: URL): boolean {
|
|
28
|
+
return url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True for `http://localhost…` and friends — input that only a local tunnel can serve. */
|
|
32
|
+
export function isLoopbackUrl(input: string): boolean {
|
|
33
|
+
return Option.exists(parseUrl(input), (url) => isHttpLoopback(url))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Preserves filesystem failures instead of treating unreadable paths as missing files. */
|
|
37
|
+
function localTargetFileError(input: string, cause: PlatformError): ExpandLocalTargetError {
|
|
38
|
+
const details = Match.value(cause).pipe(
|
|
39
|
+
Match.withReturnType<Pick<ExpandLocalTargetError, 'reason' | 'message'>>(),
|
|
40
|
+
Match.when({ _tag: 'SystemError', reason: 'NotFound' }, () => ({
|
|
41
|
+
reason: 'NotFound',
|
|
42
|
+
message: `Local path "${input}" was not found. Check the path and try again.`,
|
|
43
|
+
})),
|
|
44
|
+
Match.when({ _tag: 'SystemError', reason: 'PermissionDenied' }, () => ({
|
|
45
|
+
reason: 'PermissionDenied',
|
|
46
|
+
message: `Cannot read "${input}": permission denied. Check the file or folder permissions.`,
|
|
47
|
+
})),
|
|
48
|
+
Match.tag('SystemError', () => ({
|
|
49
|
+
reason: 'Unavailable',
|
|
50
|
+
message: `Cannot access local path "${input}". ${cause.message}`,
|
|
51
|
+
})),
|
|
52
|
+
Match.orElse(() => ({
|
|
53
|
+
reason: 'InvalidInput',
|
|
54
|
+
message: `Invalid local path "${input}". Pass a file or folder path on this machine.`,
|
|
55
|
+
})),
|
|
56
|
+
)
|
|
57
|
+
return new ExpandLocalTargetError({ input, cause, ...details })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Canonicalizes a directory before serving it and refuses anything containing the home folder. */
|
|
61
|
+
export const resolveSafeDirectory = Effect.fn('LocalTarget.resolveSafeDirectory')(function* (input: string) {
|
|
62
|
+
const fs = yield* FileSystem.FileSystem
|
|
63
|
+
const path = yield* Path.Path
|
|
64
|
+
const directory = yield* fs.realPath(input).pipe(Effect.mapError((cause) => localTargetFileError(input, cause)))
|
|
65
|
+
const home = yield* fs.realPath(homedir()).pipe(Effect.orDie)
|
|
66
|
+
const relativeHome = path.relative(directory, home)
|
|
67
|
+
const containsHome =
|
|
68
|
+
!path.isAbsolute(relativeHome) && relativeHome !== '..' && !relativeHome.startsWith(`..${path.sep}`)
|
|
69
|
+
if (containsHome) {
|
|
70
|
+
return yield* new ExpandLocalTargetError({
|
|
71
|
+
reason: 'UnsafeDirectory',
|
|
72
|
+
input,
|
|
73
|
+
message: `Refusing to share "${input}" because it contains your home folder. Choose a specific project folder or file.`,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
return directory
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const resolveFileTarget = Effect.fn('LocalTarget.resolveFileTarget')(function* (input: string, filePath: string) {
|
|
80
|
+
const fs = yield* FileSystem.FileSystem
|
|
81
|
+
const path = yield* Path.Path
|
|
82
|
+
const expanded = /^~(?=$|[/\\])/u.test(filePath) ? path.join(homedir(), filePath.slice(1)) : filePath
|
|
83
|
+
const resolved = path.resolve(expanded)
|
|
84
|
+
const info = yield* fs.stat(resolved).pipe(Effect.mapError((cause) => localTargetFileError(input, cause)))
|
|
85
|
+
if (info.type !== 'File' && info.type !== 'Directory') {
|
|
86
|
+
return yield* new ExpandLocalTargetError({
|
|
87
|
+
reason: 'UnsupportedFileType',
|
|
88
|
+
input,
|
|
89
|
+
message: `"${input}" is not a regular file or folder. Choose a file or folder to fetch.`,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
yield* fs.access(resolved, { readable: true }).pipe(Effect.mapError((cause) => localTargetFileError(input, cause)))
|
|
93
|
+
return info.type === 'Directory' ? LocalTarget.Directory({ path: resolved }) : LocalTarget.File({ path: resolved })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const resolveUrlTarget = Effect.fn('LocalTarget.resolveUrlTarget')(function* (
|
|
97
|
+
input: string,
|
|
98
|
+
parsed: Option.Option<URL>,
|
|
99
|
+
) {
|
|
100
|
+
if (Option.isNone(parsed)) {
|
|
101
|
+
return yield* new ExpandLocalTargetError({
|
|
102
|
+
reason: 'InvalidInput',
|
|
103
|
+
input,
|
|
104
|
+
message: `Invalid local URL "${input}". Check the address and port.`,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
const url = parsed.value
|
|
108
|
+
if (url.protocol === 'file:') {
|
|
109
|
+
const path = yield* Path.Path
|
|
110
|
+
const filePath = yield* path.fromFileUrl(url).pipe(Effect.mapError((cause) => localTargetFileError(input, cause)))
|
|
111
|
+
return yield* resolveFileTarget(input, filePath)
|
|
112
|
+
}
|
|
113
|
+
if (!LOOPBACK_HOSTS.has(url.hostname)) {
|
|
114
|
+
return yield* new ExpandLocalTargetError({
|
|
115
|
+
reason: 'NotLocal',
|
|
116
|
+
input,
|
|
117
|
+
message: `"${input}" is not a localhost address. Use a localhost URL or file path, or disable local mode to fetch a public URL.`,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
if (url.protocol !== 'http:') {
|
|
121
|
+
return yield* new ExpandLocalTargetError({
|
|
122
|
+
reason: 'UnsupportedProtocol',
|
|
123
|
+
input,
|
|
124
|
+
message:
|
|
125
|
+
url.protocol === 'https:'
|
|
126
|
+
? `Local HTTPS websites are not supported yet. Start an HTTP server and pass its http://localhost address instead of "${input}".`
|
|
127
|
+
: `Local websites must use HTTP. Pass an http://localhost address instead of "${input}".`,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
return LocalTarget.Port({
|
|
131
|
+
host: url.hostname === '0.0.0.0' ? 'localhost' : url.hostname,
|
|
132
|
+
port: Number(url.port || 80),
|
|
133
|
+
path: `${url.pathname}${url.search}${url.hash}`,
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/** Reads an explicitly local input or fails with a specific, actionable error. */
|
|
138
|
+
export const resolveLocalTarget = Effect.fn('LocalTarget.resolveLocalTarget')(function* (input: string) {
|
|
139
|
+
if (input.length === 0) {
|
|
140
|
+
return yield* new ExpandLocalTargetError({
|
|
141
|
+
reason: 'InvalidInput',
|
|
142
|
+
input,
|
|
143
|
+
message: 'The local input is empty. Pass a localhost URL or a local file or folder path.',
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
// Windows drive paths contain a colon but are filesystem inputs, not URL schemes.
|
|
147
|
+
const isWindowsPath = /^[a-z]:/iu.test(input)
|
|
148
|
+
const looksLikeUrl = !isWindowsPath && (/^[a-z][a-z\d+.-]*:/iu.test(input) || LOOPBACK_PREFIX.test(input))
|
|
149
|
+
const parsed = parseUrl(input)
|
|
150
|
+
return yield* looksLikeUrl || Option.exists(parsed, (url) => isHttpLoopback(url))
|
|
151
|
+
? resolveUrlTarget(input, parsed)
|
|
152
|
+
: resolveFileTarget(input, input)
|
|
153
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Array, Encoding, Schema } from 'effect'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wire protocol between a local tunnel client (CLI / SDK) and the Cloudflare
|
|
5
|
+
* relay (`packages/tunnel-relay`). One WebSocket per tunnel.
|
|
6
|
+
*
|
|
7
|
+
* Control frames are JSON text frames. Body bytes travel as binary frames:
|
|
8
|
+
* the 16-byte request id followed by the chunk. Every control frame carries
|
|
9
|
+
* `v: 1` so either side can reject a peer it does not understand.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const PROTOCOL_VERSION = 1
|
|
13
|
+
|
|
14
|
+
/** Upper bound on one binary body frame; larger bodies are split. */
|
|
15
|
+
export const MAX_BODY_FRAME_BYTES = 512 * 1024
|
|
16
|
+
/** Upper bound on each request or response body. */
|
|
17
|
+
export const MAX_BODY_BYTES = 25 * 1024 * 1024
|
|
18
|
+
/** Bounds both relay requests and local work, including uploads still being buffered. */
|
|
19
|
+
export const MAX_INFLIGHT_REQUESTS = 32
|
|
20
|
+
|
|
21
|
+
/** 16-byte request id encoded as 32 lowercase hex characters. */
|
|
22
|
+
export const RequestId = Schema.String.pipe(Schema.pattern(/^[0-9a-f]{32}$/u), Schema.brand('TunnelRequestId'))
|
|
23
|
+
export type RequestId = typeof RequestId.Type
|
|
24
|
+
|
|
25
|
+
const HeaderEntries = Schema.Array(Schema.Tuple(Schema.String, Schema.String))
|
|
26
|
+
|
|
27
|
+
const frame = <Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) =>
|
|
28
|
+
Schema.TaggedStruct(tag, { v: Schema.Literal(PROTOCOL_VERSION), ...fields })
|
|
29
|
+
|
|
30
|
+
// relay → client
|
|
31
|
+
|
|
32
|
+
/** First frame after the socket opens. `url` is the tunnel's public origin, no trailing slash. */
|
|
33
|
+
export const Hello = frame('Hello', { url: Schema.String })
|
|
34
|
+
|
|
35
|
+
export const RequestStart = frame('RequestStart', {
|
|
36
|
+
id: RequestId,
|
|
37
|
+
method: Schema.String,
|
|
38
|
+
/** Path plus query, e.g. `/docs?x=1`. */
|
|
39
|
+
path: Schema.String,
|
|
40
|
+
headers: HeaderEntries,
|
|
41
|
+
/** When true, binary body frames follow until `RequestEnd`. */
|
|
42
|
+
hasBody: Schema.Boolean,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const RequestEnd = frame('RequestEnd', { id: RequestId })
|
|
46
|
+
/** The caller disconnected, stopped reading, or exceeded a relay limit. */
|
|
47
|
+
const RequestCancel = frame('RequestCancel', { id: RequestId })
|
|
48
|
+
|
|
49
|
+
// client → relay
|
|
50
|
+
|
|
51
|
+
const ResponseStart = frame('ResponseStart', {
|
|
52
|
+
id: RequestId,
|
|
53
|
+
status: Schema.Number,
|
|
54
|
+
headers: HeaderEntries,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const ResponseEnd = frame('ResponseEnd', { id: RequestId })
|
|
58
|
+
|
|
59
|
+
/** The local origin could not be reached or failed mid-stream; the relay answers 502. */
|
|
60
|
+
const ResponseError = frame('ResponseError', { id: RequestId, message: Schema.String })
|
|
61
|
+
|
|
62
|
+
export const RelayToClient = Schema.Union(Hello, RequestStart, RequestEnd, RequestCancel)
|
|
63
|
+
export type RelayToClient = typeof RelayToClient.Type
|
|
64
|
+
|
|
65
|
+
export const ClientToRelay = Schema.Union(ResponseStart, ResponseEnd, ResponseError)
|
|
66
|
+
export type ClientToRelay = typeof ClientToRelay.Type
|
|
67
|
+
|
|
68
|
+
export const decodeRelayToClient = Schema.decodeUnknownSync(Schema.parseJson(RelayToClient))
|
|
69
|
+
export const decodeClientToRelay = Schema.decodeUnknownSync(Schema.parseJson(ClientToRelay))
|
|
70
|
+
export const encodeRelayToClient = Schema.encodeSync(Schema.parseJson(RelayToClient))
|
|
71
|
+
export const encodeClientToRelay = Schema.encodeSync(Schema.parseJson(ClientToRelay))
|
|
72
|
+
|
|
73
|
+
const REQUEST_ID_BYTES = 16
|
|
74
|
+
const decodeRequestIdBytes = Schema.decodeSync(Schema.Uint8ArrayFromHex)
|
|
75
|
+
|
|
76
|
+
/** Binary frame layout: 16 id bytes, then the chunk. */
|
|
77
|
+
export function encodeBodyFrame(id: RequestId, chunk: Uint8Array): Uint8Array {
|
|
78
|
+
const out = new Uint8Array(REQUEST_ID_BYTES + chunk.byteLength)
|
|
79
|
+
out.set(decodeRequestIdBytes(id))
|
|
80
|
+
out.set(chunk, REQUEST_ID_BYTES)
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function decodeBodyFrame(frameBytes: Uint8Array): { readonly id: RequestId; readonly chunk: Uint8Array } {
|
|
85
|
+
if (frameBytes.byteLength < REQUEST_ID_BYTES) {
|
|
86
|
+
throw new Error(`Tunnel body frame shorter than ${REQUEST_ID_BYTES} bytes`)
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
id: RequestId.make(Encoding.encodeHex(frameBytes.subarray(0, REQUEST_ID_BYTES))),
|
|
90
|
+
chunk: frameBytes.subarray(REQUEST_ID_BYTES),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Views over a body chunk that fit the protocol's frame-size limit. */
|
|
95
|
+
export function splitBodyChunk(chunk: Uint8Array): Uint8Array[] {
|
|
96
|
+
return Array.makeBy(Math.ceil(chunk.byteLength / MAX_BODY_FRAME_BYTES), (index) =>
|
|
97
|
+
chunk.subarray(index * MAX_BODY_FRAME_BYTES, (index + 1) * MAX_BODY_FRAME_BYTES),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { FileSystem, HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse, Path } from '@effect/platform'
|
|
2
|
+
import { Effect, Match, Option } from 'effect'
|
|
3
|
+
import mime from 'mime'
|
|
4
|
+
|
|
5
|
+
const parseRequestUrl = Option.liftThrowable((input: string) => {
|
|
6
|
+
const url = new URL(input, 'http://localhost')
|
|
7
|
+
return { url, decodedPathname: decodeURIComponent(url.pathname) }
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
interface ResolvedFile {
|
|
11
|
+
readonly path: string
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly directory: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Resolve = (
|
|
17
|
+
pathname: string,
|
|
18
|
+
) => Effect.Effect<Option.Option<ResolvedFile>, never, FileSystem.FileSystem | Path.Path>
|
|
19
|
+
|
|
20
|
+
/** Installs a read-only handler on the supplied server until the scope closes, and returns its TCP port. */
|
|
21
|
+
const serve = Effect.fn('StaticServer.serve')(function* (resolve: Resolve) {
|
|
22
|
+
const notFound = HttpServerResponse.text('Not found', { status: 404 })
|
|
23
|
+
const server = yield* HttpServer.HttpServer
|
|
24
|
+
yield* server.serve(
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
27
|
+
const parsed = parseRequestUrl(request.url)
|
|
28
|
+
if (Option.isNone(parsed)) return notFound
|
|
29
|
+
const { url, decodedPathname } = parsed.value
|
|
30
|
+
const file = yield* resolve(decodedPathname)
|
|
31
|
+
if (Option.isNone(file)) return notFound
|
|
32
|
+
if (file.value.directory && !url.pathname.endsWith('/')) {
|
|
33
|
+
return HttpServerResponse.redirect(`${url.pathname}/${url.search}`)
|
|
34
|
+
}
|
|
35
|
+
// A symlink's public name determines its content type, while reads use its validated destination.
|
|
36
|
+
return yield* HttpServerResponse.file(file.value.path, {
|
|
37
|
+
headers: { 'content-type': mime.getType(file.value.name) ?? 'application/octet-stream' },
|
|
38
|
+
}).pipe(Effect.orElseSucceed(() => notFound))
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
return yield* Match.valueTags(server.address, {
|
|
42
|
+
TcpAddress: ({ port }) => Effect.succeed(port),
|
|
43
|
+
UnixAddress: () => new HttpServerError.ServeError({ cause: new Error('Local file serving requires a TCP port.') }),
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** Serves exactly one file at `/<basename>`; nothing else on the machine is reachable. */
|
|
48
|
+
export const serveFile = (file: string) =>
|
|
49
|
+
serve((pathname) =>
|
|
50
|
+
Effect.map(Path.Path, (path) => {
|
|
51
|
+
const name = path.basename(file)
|
|
52
|
+
return pathname === `/${name}` ? Option.some({ path: file, name, directory: false }) : Option.none()
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Serves `root`; directories answer with their `index.html`. Dotfiles (`.env`, `.git/`) and anything
|
|
58
|
+
* resolving outside `root`, through `..` or a symlink, are a 404.
|
|
59
|
+
*/
|
|
60
|
+
export const serveDirectory = (root: string) =>
|
|
61
|
+
serve(
|
|
62
|
+
Effect.fn('StaticServer.resolveInDirectory')(function* (pathname: string) {
|
|
63
|
+
const fs = yield* FileSystem.FileSystem
|
|
64
|
+
const path = yield* Path.Path
|
|
65
|
+
if (pathname.split('/').some((segment) => segment.startsWith('.'))) return Option.none<ResolvedFile>()
|
|
66
|
+
const candidate = path.join(root, pathname)
|
|
67
|
+
const info = yield* fs.stat(candidate).pipe(Effect.option)
|
|
68
|
+
if (Option.isNone(info)) return Option.none<ResolvedFile>()
|
|
69
|
+
const file = info.value.type === 'Directory' ? path.join(candidate, 'index.html') : candidate
|
|
70
|
+
const real = yield* Effect.all([fs.realPath(root), fs.realPath(file)]).pipe(Effect.option)
|
|
71
|
+
if (Option.isNone(real)) return Option.none<ResolvedFile>()
|
|
72
|
+
const [realRoot, realFile] = real.value
|
|
73
|
+
const relative = path.relative(realRoot, realFile)
|
|
74
|
+
const inside =
|
|
75
|
+
relative !== '' && !path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)
|
|
76
|
+
// A visible symlink may resolve to `.env` or into `.git` inside the root.
|
|
77
|
+
const hidden = relative.split(path.sep).some((segment) => segment.startsWith('.'))
|
|
78
|
+
if (!inside || hidden) return Option.none<ResolvedFile>()
|
|
79
|
+
// Opening a FIFO can block a filesystem worker even after the HTTP request is cancelled.
|
|
80
|
+
const destination = yield* fs.stat(realFile).pipe(Effect.option)
|
|
81
|
+
return Option.exists(destination, (entry) => entry.type === 'File')
|
|
82
|
+
? Option.some({ path: realFile, name: path.basename(file), directory: info.value.type === 'Directory' })
|
|
83
|
+
: Option.none<ResolvedFile>()
|
|
84
|
+
}),
|
|
85
|
+
)
|