@effect-uai/firecrawl 0.10.0 → 0.12.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.
@@ -6,7 +6,7 @@ import { CommonReadRequest, ReadResponse, WebRead } from "@effect-uai/core/WebRe
6
6
 
7
7
  //#region src/FirecrawlRead.d.ts
8
8
  declare namespace FirecrawlRead_d_exports {
9
- export { Config, FirecrawlRead, FirecrawlReadRequest, FirecrawlReadService, layer, make };
9
+ export { Config, FirecrawlMapRequest, FirecrawlRead, FirecrawlReadRequest, FirecrawlReadService, MapLink, MapResponse, layer, make };
10
10
  }
11
11
  /**
12
12
  * Firecrawl-typed read request. Widens {@link CommonReadRequest} with
@@ -21,8 +21,37 @@ type FirecrawlReadRequest = CommonReadRequest & {
21
21
  readonly mobile?: boolean; /** Proxy tier - see {@link FirecrawlProxy}. */
22
22
  readonly proxy?: FirecrawlProxy;
23
23
  };
24
+ /**
25
+ * Firecrawl-typed map (sitemap / URL discovery) request. Given a base `url`,
26
+ * Firecrawl returns the site's URLs without scraping each one - handy for
27
+ * locating specific pages (pricing, docs) before a targeted read. All fields
28
+ * but `url` are optional.
29
+ */
30
+ type FirecrawlMapRequest = {
31
+ readonly url: string; /** Order results by relevance to this query. */
32
+ readonly search?: string; /** Cap the number of links returned. */
33
+ readonly limit?: number; /** How to use the site's sitemap: `only` (sitemap alone), `include`, or `skip`. */
34
+ readonly sitemap?: "only" | "include" | "skip"; /** Include links on subdomains of the base URL. */
35
+ readonly includeSubdomains?: boolean; /** Abort the map if it runs past this duration. */
36
+ readonly timeout?: Duration.Duration;
37
+ };
38
+ /** One discovered URL, with page metadata where Firecrawl has it. */
39
+ type MapLink = {
40
+ readonly url: string;
41
+ readonly title?: string;
42
+ readonly description?: string;
43
+ };
44
+ type MapResponse = {
45
+ readonly links: ReadonlyArray<MapLink>;
46
+ readonly raw: unknown;
47
+ };
24
48
  type FirecrawlReadService = {
25
49
  readonly read: (request: FirecrawlReadRequest) => Effect.Effect<ReadResponse, AiError.AiError>;
50
+ /**
51
+ * Discover a site's URLs (sitemap-style) without scraping. Firecrawl-specific;
52
+ * not on the generic `WebRead` tag, whose contract is a single-URL read.
53
+ */
54
+ readonly map: (request: FirecrawlMapRequest) => Effect.Effect<MapResponse, AiError.AiError>;
26
55
  };
27
56
  declare const FirecrawlRead_base: Context.ServiceClass<FirecrawlRead, "@betalyra/effect-uai/providers/firecrawl/FirecrawlRead", FirecrawlReadService>;
28
57
  /**
@@ -48,5 +77,5 @@ declare const make: (cfg: Config) => Effect.Effect<FirecrawlReadService, never,
48
77
  */
49
78
  declare const layer: (cfg: Config) => Layer.Layer<FirecrawlRead | WebRead, never, HttpClient.HttpClient>;
50
79
  //#endregion
51
- export { Config, FirecrawlRead, FirecrawlReadRequest, FirecrawlReadService, layer, make, FirecrawlRead_d_exports as t };
80
+ export { Config, FirecrawlMapRequest, FirecrawlRead, FirecrawlReadRequest, FirecrawlReadService, MapLink, MapResponse, layer, make, FirecrawlRead_d_exports as t };
52
81
  //# sourceMappingURL=FirecrawlRead.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FirecrawlRead.d.mts","names":[],"sources":["../src/FirecrawlRead.ts"],"mappings":";;;;;;;;;;;;;;;KAoBY,oBAAA,GAAuB,iBAAA;iFAExB,eAAA;WAEA,WAAA,GAAc,aAAA;WAEd,WAAA,GAAc,aAAA,UANO;EAAA,SAQrB,OAAA,GAAU,QAAA,CAAS,QAAA,EARK;EAAA,SAUxB,MAAA,YAJc;EAAA,SAMd,KAAA,GAAQ,cAAA;AAAA;AAAA,KAGP,oBAAA;EAAA,SACD,IAAA,GAAO,OAAA,EAAS,oBAAA,KAAyB,MAAA,CAAO,MAAA,CAAO,YAAA,EAAc,OAAA,CAAQ,OAAA;AAAA;AAAA,cACvF,kBAAA;;;;;;cAOY,aAAA,SAAsB,kBAAA;AAAA,KAIvB,MAAA;EAAA,SACD,MAAA,EAAQ,QAAA,CAAS,QAAA;EAAA,SACjB,OAAA;AAAA;;AAfX;;;cAyJa,IAAA,GACX,GAAA,EAAK,MAAA,KACJ,MAAA,CAAO,MAAA,CAAO,oBAAA,SAA6B,UAAA,CAAW,UAAA;;;;;;;cAY5C,KAAA,GACX,GAAA,EAAK,MAAA,KACJ,KAAA,CAAM,KAAA,CAAM,aAAA,GAAgB,OAAA,SAAgB,UAAA,CAAW,UAAA"}
1
+ {"version":3,"file":"FirecrawlRead.d.mts","names":[],"sources":["../src/FirecrawlRead.ts"],"mappings":";;;;;;;;;;;;;;;KAoBY,oBAAA,GAAuB,iBAAA;iFAExB,eAAA;WAEA,WAAA,GAAc,aAAA;WAEd,WAAA,GAAc,aAAA;WAEd,OAAA,GAAU,QAAA,CAAS,QAAA,EARlB;EAAA,SAUD,MAAA;WAEA,KAAA,GAAQ,cAAA;AAAA;;;;;;;KASP,mBAAA;EAAA,SACD,GAAA,UAlBc;EAAA,SAoBd,MAAA,WAlBc;EAAA,SAoBd,KAAA,WAlBU;EAAA,SAoBV,OAAA,gCAlBA;EAAA,SAoBA,iBAAA,YAlBQ;EAAA,SAoBR,OAAA,GAAU,QAAA,CAAS,QAAA;AAAA;AAX9B;AAAA,KAeY,OAAA;EAAA,SACD,GAAA;EAAA,SACA,KAAA;EAAA,SACA,WAAA;AAAA;AAAA,KAGC,WAAA;EAAA,SACD,KAAA,EAAO,aAAA,CAAc,OAAA;EAAA,SACrB,GAAA;AAAA;AAAA,KAGC,oBAAA;EAAA,SACD,IAAA,GAAO,OAAA,EAAS,oBAAA,KAAyB,MAAA,CAAO,MAAA,CAAO,YAAA,EAAc,OAAA,CAAQ,OAAA;EAhBlD;;AAItC;;EAJsC,SAqB3B,GAAA,GAAM,OAAA,EAAS,mBAAA,KAAwB,MAAA,CAAO,MAAA,CAAO,WAAA,EAAa,OAAA,CAAQ,OAAA;AAAA;AAAA,cACpF,kBAAA;;;;;AAZD;cAmBa,aAAA,SAAsB,kBAAA;AAAA,KAIvB,MAAA;EAAA,SACD,MAAA,EAAQ,QAAA,CAAS,QAAA;EAAA,SACjB,OAAA;AAAA;;;;AApBX;cA2Qa,IAAA,GACX,GAAA,EAAK,MAAA,KACJ,MAAA,CAAO,MAAA,CAAO,oBAAA,SAA6B,UAAA,CAAW,UAAA;;;;;;;cAc5C,KAAA,GACX,GAAA,EAAK,MAAA,KACJ,KAAA,CAAM,KAAA,CAAM,aAAA,GAAgB,OAAA,SAAgB,UAAA,CAAW,UAAA"}
@@ -29,6 +29,14 @@ const buildBody = (request) => {
29
29
  ...request.proxy !== void 0 && { proxy: request.proxy }
30
30
  };
31
31
  };
32
+ const buildMapBody = (request) => ({
33
+ url: request.url,
34
+ ...request.search !== void 0 && { search: request.search },
35
+ ...request.limit !== void 0 && { limit: request.limit },
36
+ ...request.sitemap !== void 0 && { sitemap: request.sitemap },
37
+ ...request.includeSubdomains !== void 0 && { includeSubdomains: request.includeSubdomains },
38
+ ...request.timeout !== void 0 && { timeout: Duration.toMillis(request.timeout) }
39
+ });
32
40
  const WireMetadata = Schema.Struct({
33
41
  title: Schema.optional(Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)]))),
34
42
  sourceURL: Schema.optional(Schema.NullOr(Schema.String)),
@@ -41,13 +49,20 @@ const WireData = Schema.Struct({
41
49
  links: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
42
50
  metadata: Schema.optional(WireMetadata)
43
51
  });
52
+ /**
53
+ * `data` is optional because a failed scrape is not an HTTP failure. Firecrawl
54
+ * answers 200 with `{ success: false, error }` when a page is blocked, times
55
+ * out or renders only under JS — so requiring `data` turned every one of those
56
+ * into a decode error, which was then reported as a transport fault and
57
+ * retried on the backoff schedule.
58
+ */
44
59
  const WireResponse = Schema.Struct({
45
60
  success: Schema.optional(Schema.Boolean),
46
- data: WireData
61
+ data: Schema.optional(WireData),
62
+ error: Schema.optional(Schema.NullOr(Schema.String))
47
63
  });
48
64
  const pickTitle = (title) => typeof title === "string" ? title : Array.isArray(title) ? title[0] : void 0;
49
- const toResponse = (wire, request) => {
50
- const { data } = wire;
65
+ const toResponse = (wire, data, request) => {
51
66
  const content = ((request.format ?? "markdown") === "html" ? data.html : data.markdown) ?? "";
52
67
  const title = pickTitle(data.metadata?.title);
53
68
  const links = data.links ?? void 0;
@@ -59,10 +74,43 @@ const toResponse = (wire, request) => {
59
74
  raw: wire
60
75
  };
61
76
  };
77
+ const WireMapLink = Schema.Struct({
78
+ url: Schema.String,
79
+ title: Schema.optional(Schema.NullOr(Schema.String)),
80
+ description: Schema.optional(Schema.NullOr(Schema.String))
81
+ });
82
+ const WireMapResponse = Schema.Struct({
83
+ success: Schema.optional(Schema.Boolean),
84
+ links: Schema.Array(Schema.Union([WireMapLink, Schema.String]))
85
+ });
86
+ const toMapLink = (link) => typeof link === "string" ? { url: link } : {
87
+ url: link.url,
88
+ ...link.title != null && { title: link.title },
89
+ ...link.description != null && { description: link.description }
90
+ };
91
+ const toMapResponse = (wire) => ({
92
+ links: wire.links.map(toMapLink),
93
+ raw: wire
94
+ });
62
95
  const transportFailure = (cause) => new AiError.Unavailable({
63
96
  provider: "firecrawl",
64
97
  raw: cause
65
98
  });
99
+ /**
100
+ * The call arrived and the answer is unusable — a scrape Firecrawl itself
101
+ * reports as failed, or a body that does not match what this provider knows how
102
+ * to read.
103
+ *
104
+ * Deliberately not `Unavailable`: that tag is retryable, and neither of these
105
+ * gets better on a second attempt. A blocked page stays blocked while the
106
+ * backoff spends the scrape budget on it, and a changed API shape is a code
107
+ * problem, not a weather problem.
108
+ */
109
+ const unusableResponse = (message, raw) => new AiError.GenerationFailed({
110
+ provider: "firecrawl",
111
+ message,
112
+ raw
113
+ });
66
114
  const httpStatusError = (status, body) => {
67
115
  const provider = "firecrawl";
68
116
  const raw = body;
@@ -109,14 +157,29 @@ const postScrape = (cfg, body) => Effect.gen(function* () {
109
157
  return yield* httpStatusError(response.status, text);
110
158
  }
111
159
  const json = yield* response.json.pipe(Effect.mapError(transportFailure));
112
- return yield* Schema.decodeUnknownEffect(WireResponse)(json).pipe(Effect.mapError(transportFailure));
160
+ return yield* Schema.decodeUnknownEffect(WireResponse)(json).pipe(Effect.mapError((issue) => unusableResponse("firecrawl returned a body this provider cannot read", issue)));
161
+ });
162
+ const readImpl = (cfg) => (request) => Effect.flatMap(postScrape(cfg, buildBody(request)), (wire) => wire.data === void 0 || wire.success === false ? Effect.fail(unusableResponse(wire.error ?? `firecrawl could not scrape ${request.url}`, wire)) : Effect.succeed(toResponse(wire, wire.data, request)));
163
+ const postMap = (cfg, body) => Effect.gen(function* () {
164
+ const client = yield* HttpClient.HttpClient;
165
+ const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/v2/map`).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${Redacted.value(cfg.apiKey)}`), HttpClientRequest.bodyJsonUnsafe(body));
166
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure));
167
+ if (response.status >= 400) {
168
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""));
169
+ return yield* httpStatusError(response.status, text);
170
+ }
171
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure));
172
+ return yield* Schema.decodeUnknownEffect(WireMapResponse)(json).pipe(Effect.mapError(transportFailure));
113
173
  });
114
- const readImpl = (cfg) => (request) => Effect.map(postScrape(cfg, buildBody(request)), (wire) => toResponse(wire, request));
174
+ const mapImpl = (cfg) => (request) => Effect.map(postMap(cfg, buildMapBody(request)), toMapResponse);
115
175
  /**
116
176
  * Build a `FirecrawlReadService` value. For Layer-based setup, prefer
117
177
  * {@link layer}.
118
178
  */
119
- const make = (cfg) => Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({ read: (request) => readImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)) }));
179
+ const make = (cfg) => Effect.map(HttpClient.HttpClient, (client) => ({
180
+ read: (request) => readImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
181
+ map: (request) => mapImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client))
182
+ }));
120
183
  /**
121
184
  * Layer registering both the provider-typed `FirecrawlRead` tag and the
122
185
  * generic `WebRead` tag over one implementation. A `CommonReadRequest` is
@@ -1 +1 @@
1
- {"version":3,"file":"FirecrawlRead.mjs","names":[],"sources":["../src/FirecrawlRead.ts"],"sourcesContent":["import { Context, Duration, Effect, Layer, Redacted, Schema } from \"effect\"\nimport { HttpClient, HttpClientRequest } from \"effect/unstable/http\"\nimport * as AiError from \"@effect-uai/core/AiError\"\nimport {\n type CommonReadRequest,\n type ReadResponse,\n WebRead,\n type WebReadService,\n} from \"@effect-uai/core/WebRead\"\nimport type { FirecrawlProxy } from \"./models.js\"\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/**\n * Firecrawl-typed read request. Widens {@link CommonReadRequest} with\n * Firecrawl's own scrape knobs; the common fields map onto Firecrawl's wire\n * body (see the codec below).\n */\nexport type FirecrawlReadRequest = CommonReadRequest & {\n /** Keep only the main content, dropping nav/header/footer/sidebar. */\n readonly onlyMainContent?: boolean\n /** Restrict extraction to these HTML tags / selectors. */\n readonly includeTags?: ReadonlyArray<string>\n /** Drop these HTML tags / selectors before extraction. */\n readonly excludeTags?: ReadonlyArray<string>\n /** Wait this long for the page to settle before scraping (JS-heavy sites). */\n readonly waitFor?: Duration.Duration\n /** Emulate a mobile device. */\n readonly mobile?: boolean\n /** Proxy tier - see {@link FirecrawlProxy}. */\n readonly proxy?: FirecrawlProxy\n}\n\nexport type FirecrawlReadService = {\n readonly read: (request: FirecrawlReadRequest) => Effect.Effect<ReadResponse, AiError.AiError>\n}\n\n/**\n * Provider-typed service tag. Yield this for Firecrawl-typed reads (its own\n * scrape knobs); yield the generic `WebRead` tag for provider-portable code.\n * Both are registered by {@link layer}.\n */\nexport class FirecrawlRead extends Context.Service<FirecrawlRead, FirecrawlReadService>()(\n \"@betalyra/effect-uai/providers/firecrawl/FirecrawlRead\",\n) {}\n\nexport type Config = {\n readonly apiKey: Redacted.Redacted\n readonly baseUrl?: string\n}\n\n// ---------------------------------------------------------------------------\n// Codec - request body\n// ---------------------------------------------------------------------------\n\ntype WireBody = {\n readonly url: string\n readonly formats: ReadonlyArray<string>\n readonly onlyMainContent?: boolean\n readonly includeTags?: ReadonlyArray<string>\n readonly excludeTags?: ReadonlyArray<string>\n readonly waitFor?: number\n readonly timeout?: number\n readonly mobile?: boolean\n readonly proxy?: string\n}\n\n// `markdown` / `html` map one-to-one onto Firecrawl's format names. `links` is\n// always requested so {@link ReadResponse.links} can be populated; it does not\n// change the per-page billing.\nconst buildBody = (request: FirecrawlReadRequest): WireBody => {\n const contentFormat = request.format ?? \"markdown\"\n return {\n url: request.url,\n formats: [contentFormat, \"links\"],\n ...(request.onlyMainContent !== undefined && { onlyMainContent: request.onlyMainContent }),\n ...(request.includeTags !== undefined && { includeTags: request.includeTags }),\n ...(request.excludeTags !== undefined && { excludeTags: request.excludeTags }),\n ...(request.waitFor !== undefined && { waitFor: Duration.toMillis(request.waitFor) }),\n ...(request.timeout !== undefined && { timeout: Duration.toMillis(request.timeout) }),\n ...(request.mobile !== undefined && { mobile: request.mobile }),\n ...(request.proxy !== undefined && { proxy: request.proxy }),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Codec - response\n// ---------------------------------------------------------------------------\n\nconst WireMetadata = Schema.Struct({\n // Firecrawl returns `title` as a string, an array (repeated meta tags), or\n // null; normalize to a single string in `toResponse`.\n title: Schema.optional(Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)]))),\n sourceURL: Schema.optional(Schema.NullOr(Schema.String)),\n statusCode: Schema.optional(Schema.Number),\n})\n\nconst WireData = Schema.Struct({\n markdown: Schema.optional(Schema.NullOr(Schema.String)),\n html: Schema.optional(Schema.NullOr(Schema.String)),\n rawHtml: Schema.optional(Schema.NullOr(Schema.String)),\n links: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),\n metadata: Schema.optional(WireMetadata),\n})\n\nconst WireResponse = Schema.Struct({\n success: Schema.optional(Schema.Boolean),\n data: WireData,\n})\ntype WireResponse = typeof WireResponse.Type\n\nconst pickTitle = (title: string | ReadonlyArray<string> | null | undefined): string | undefined =>\n typeof title === \"string\" ? title : Array.isArray(title) ? title[0] : undefined\n\nconst toResponse = (wire: WireResponse, request: FirecrawlReadRequest): ReadResponse => {\n const { data } = wire\n const format = request.format ?? \"markdown\"\n const content = (format === \"html\" ? data.html : data.markdown) ?? \"\"\n const title = pickTitle(data.metadata?.title)\n const links = data.links ?? undefined\n return {\n url: data.metadata?.sourceURL ?? request.url,\n content,\n ...(title !== undefined && { title }),\n ...(links !== undefined && links.length > 0 && { links }),\n raw: wire,\n }\n}\n\n// ---------------------------------------------------------------------------\n// HTTP\n// ---------------------------------------------------------------------------\n\nconst transportFailure = (cause: unknown): AiError.AiError =>\n new AiError.Unavailable({ provider: \"firecrawl\", raw: cause })\n\nconst httpStatusError = (status: number, body: string): AiError.AiError => {\n const provider = \"firecrawl\"\n const raw = body\n if (status === 429) return new AiError.RateLimited({ provider, raw })\n if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })\n if (status === 401) return new AiError.AuthFailed({ provider, subtype: \"auth\", raw })\n if (status === 403) return new AiError.AuthFailed({ provider, subtype: \"permission\", raw })\n if (status === 402) return new AiError.AuthFailed({ provider, subtype: \"billing\", raw })\n if (status >= 500) return new AiError.Unavailable({ provider, status, raw })\n return new AiError.InvalidRequest({ provider, raw })\n}\n\nconst baseUrl = (cfg: Config): string => cfg.baseUrl ?? \"https://api.firecrawl.dev\"\n\nconst postScrape = (\n cfg: Config,\n body: WireBody,\n): Effect.Effect<WireResponse, AiError.AiError, HttpClient.HttpClient> =>\n Effect.gen(function* () {\n const client = yield* HttpClient.HttpClient\n const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/v2/scrape`).pipe(\n HttpClientRequest.setHeader(\"Authorization\", `Bearer ${Redacted.value(cfg.apiKey)}`),\n HttpClientRequest.bodyJsonUnsafe(body),\n )\n const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))\n if (response.status >= 400) {\n const text = yield* response.text.pipe(Effect.orElseSucceed(() => \"\"))\n return yield* httpStatusError(response.status, text)\n }\n const json = yield* response.json.pipe(Effect.mapError(transportFailure))\n return yield* Schema.decodeUnknownEffect(WireResponse)(json).pipe(\n Effect.mapError(transportFailure),\n )\n })\n\nconst readImpl =\n (cfg: Config) =>\n (\n request: FirecrawlReadRequest,\n ): Effect.Effect<ReadResponse, AiError.AiError, HttpClient.HttpClient> =>\n Effect.map(postScrape(cfg, buildBody(request)), (wire) => toResponse(wire, request))\n\n// ---------------------------------------------------------------------------\n// Constructors\n// ---------------------------------------------------------------------------\n\n/**\n * Build a `FirecrawlReadService` value. For Layer-based setup, prefer\n * {@link layer}.\n */\nexport const make = (\n cfg: Config,\n): Effect.Effect<FirecrawlReadService, never, HttpClient.HttpClient> =>\n Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({\n read: (request) =>\n readImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),\n }))\n\n/**\n * Layer registering both the provider-typed `FirecrawlRead` tag and the\n * generic `WebRead` tag over one implementation. A `CommonReadRequest` is\n * structurally a `FirecrawlReadRequest` with the extra knobs unset, so the\n * generic registration forwards directly.\n */\nexport const layer = (\n cfg: Config,\n): Layer.Layer<FirecrawlRead | WebRead, never, HttpClient.HttpClient> => {\n const typed = Layer.effect(FirecrawlRead, make(cfg))\n const generic = Layer.effect(\n WebRead,\n Effect.map(make(cfg), (s): WebReadService => ({ read: (request) => s.read(request) })),\n )\n return Layer.merge(typed, generic)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"FirecrawlRead.mjs","names":[],"sources":["../src/FirecrawlRead.ts"],"sourcesContent":["import { Context, Duration, Effect, Layer, Redacted, Schema } from \"effect\"\nimport { HttpClient, HttpClientRequest } from \"effect/unstable/http\"\nimport * as AiError from \"@effect-uai/core/AiError\"\nimport {\n type CommonReadRequest,\n type ReadResponse,\n WebRead,\n type WebReadService,\n} from \"@effect-uai/core/WebRead\"\nimport type { FirecrawlProxy } from \"./models.js\"\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/**\n * Firecrawl-typed read request. Widens {@link CommonReadRequest} with\n * Firecrawl's own scrape knobs; the common fields map onto Firecrawl's wire\n * body (see the codec below).\n */\nexport type FirecrawlReadRequest = CommonReadRequest & {\n /** Keep only the main content, dropping nav/header/footer/sidebar. */\n readonly onlyMainContent?: boolean\n /** Restrict extraction to these HTML tags / selectors. */\n readonly includeTags?: ReadonlyArray<string>\n /** Drop these HTML tags / selectors before extraction. */\n readonly excludeTags?: ReadonlyArray<string>\n /** Wait this long for the page to settle before scraping (JS-heavy sites). */\n readonly waitFor?: Duration.Duration\n /** Emulate a mobile device. */\n readonly mobile?: boolean\n /** Proxy tier - see {@link FirecrawlProxy}. */\n readonly proxy?: FirecrawlProxy\n}\n\n/**\n * Firecrawl-typed map (sitemap / URL discovery) request. Given a base `url`,\n * Firecrawl returns the site's URLs without scraping each one - handy for\n * locating specific pages (pricing, docs) before a targeted read. All fields\n * but `url` are optional.\n */\nexport type FirecrawlMapRequest = {\n readonly url: string\n /** Order results by relevance to this query. */\n readonly search?: string\n /** Cap the number of links returned. */\n readonly limit?: number\n /** How to use the site's sitemap: `only` (sitemap alone), `include`, or `skip`. */\n readonly sitemap?: \"only\" | \"include\" | \"skip\"\n /** Include links on subdomains of the base URL. */\n readonly includeSubdomains?: boolean\n /** Abort the map if it runs past this duration. */\n readonly timeout?: Duration.Duration\n}\n\n/** One discovered URL, with page metadata where Firecrawl has it. */\nexport type MapLink = {\n readonly url: string\n readonly title?: string\n readonly description?: string\n}\n\nexport type MapResponse = {\n readonly links: ReadonlyArray<MapLink>\n readonly raw: unknown\n}\n\nexport type FirecrawlReadService = {\n readonly read: (request: FirecrawlReadRequest) => Effect.Effect<ReadResponse, AiError.AiError>\n /**\n * Discover a site's URLs (sitemap-style) without scraping. Firecrawl-specific;\n * not on the generic `WebRead` tag, whose contract is a single-URL read.\n */\n readonly map: (request: FirecrawlMapRequest) => Effect.Effect<MapResponse, AiError.AiError>\n}\n\n/**\n * Provider-typed service tag. Yield this for Firecrawl-typed reads (its own\n * scrape knobs); yield the generic `WebRead` tag for provider-portable code.\n * Both are registered by {@link layer}.\n */\nexport class FirecrawlRead extends Context.Service<FirecrawlRead, FirecrawlReadService>()(\n \"@betalyra/effect-uai/providers/firecrawl/FirecrawlRead\",\n) {}\n\nexport type Config = {\n readonly apiKey: Redacted.Redacted\n readonly baseUrl?: string\n}\n\n// ---------------------------------------------------------------------------\n// Codec - request body\n// ---------------------------------------------------------------------------\n\ntype WireBody = {\n readonly url: string\n readonly formats: ReadonlyArray<string>\n readonly onlyMainContent?: boolean\n readonly includeTags?: ReadonlyArray<string>\n readonly excludeTags?: ReadonlyArray<string>\n readonly waitFor?: number\n readonly timeout?: number\n readonly mobile?: boolean\n readonly proxy?: string\n}\n\n// `markdown` / `html` map one-to-one onto Firecrawl's format names. `links` is\n// always requested so {@link ReadResponse.links} can be populated; it does not\n// change the per-page billing.\nconst buildBody = (request: FirecrawlReadRequest): WireBody => {\n const contentFormat = request.format ?? \"markdown\"\n return {\n url: request.url,\n formats: [contentFormat, \"links\"],\n ...(request.onlyMainContent !== undefined && { onlyMainContent: request.onlyMainContent }),\n ...(request.includeTags !== undefined && { includeTags: request.includeTags }),\n ...(request.excludeTags !== undefined && { excludeTags: request.excludeTags }),\n ...(request.waitFor !== undefined && { waitFor: Duration.toMillis(request.waitFor) }),\n ...(request.timeout !== undefined && { timeout: Duration.toMillis(request.timeout) }),\n ...(request.mobile !== undefined && { mobile: request.mobile }),\n ...(request.proxy !== undefined && { proxy: request.proxy }),\n }\n}\n\ntype WireMapBody = {\n readonly url: string\n readonly search?: string\n readonly limit?: number\n readonly sitemap?: string\n readonly includeSubdomains?: boolean\n readonly timeout?: number\n}\n\nconst buildMapBody = (request: FirecrawlMapRequest): WireMapBody => ({\n url: request.url,\n ...(request.search !== undefined && { search: request.search }),\n ...(request.limit !== undefined && { limit: request.limit }),\n ...(request.sitemap !== undefined && { sitemap: request.sitemap }),\n ...(request.includeSubdomains !== undefined && { includeSubdomains: request.includeSubdomains }),\n ...(request.timeout !== undefined && { timeout: Duration.toMillis(request.timeout) }),\n})\n\n// ---------------------------------------------------------------------------\n// Codec - response\n// ---------------------------------------------------------------------------\n\nconst WireMetadata = Schema.Struct({\n // Firecrawl returns `title` as a string, an array (repeated meta tags), or\n // null; normalize to a single string in `toResponse`.\n title: Schema.optional(Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)]))),\n sourceURL: Schema.optional(Schema.NullOr(Schema.String)),\n statusCode: Schema.optional(Schema.Number),\n})\n\nconst WireData = Schema.Struct({\n markdown: Schema.optional(Schema.NullOr(Schema.String)),\n html: Schema.optional(Schema.NullOr(Schema.String)),\n rawHtml: Schema.optional(Schema.NullOr(Schema.String)),\n links: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),\n metadata: Schema.optional(WireMetadata),\n})\n\n/**\n * `data` is optional because a failed scrape is not an HTTP failure. Firecrawl\n * answers 200 with `{ success: false, error }` when a page is blocked, times\n * out or renders only under JS — so requiring `data` turned every one of those\n * into a decode error, which was then reported as a transport fault and\n * retried on the backoff schedule.\n */\nconst WireResponse = Schema.Struct({\n success: Schema.optional(Schema.Boolean),\n data: Schema.optional(WireData),\n error: Schema.optional(Schema.NullOr(Schema.String)),\n})\ntype WireResponse = typeof WireResponse.Type\n\nconst pickTitle = (title: string | ReadonlyArray<string> | null | undefined): string | undefined =>\n typeof title === \"string\" ? title : Array.isArray(title) ? title[0] : undefined\n\nconst toResponse = (\n wire: WireResponse,\n data: NonNullable<WireResponse[\"data\"]>,\n request: FirecrawlReadRequest,\n): ReadResponse => {\n const format = request.format ?? \"markdown\"\n const content = (format === \"html\" ? data.html : data.markdown) ?? \"\"\n const title = pickTitle(data.metadata?.title)\n const links = data.links ?? undefined\n return {\n url: data.metadata?.sourceURL ?? request.url,\n content,\n ...(title !== undefined && { title }),\n ...(links !== undefined && links.length > 0 && { links }),\n raw: wire,\n }\n}\n\n// v2 returns link objects; older responses returned bare URL strings. Accept\n// both and normalize a string to `{ url }` in `toMapResponse`.\nconst WireMapLink = Schema.Struct({\n url: Schema.String,\n title: Schema.optional(Schema.NullOr(Schema.String)),\n description: Schema.optional(Schema.NullOr(Schema.String)),\n})\n\nconst WireMapResponse = Schema.Struct({\n success: Schema.optional(Schema.Boolean),\n links: Schema.Array(Schema.Union([WireMapLink, Schema.String])),\n})\ntype WireMapResponse = typeof WireMapResponse.Type\n\nconst toMapLink = (link: typeof WireMapLink.Type | string): MapLink =>\n typeof link === \"string\"\n ? { url: link }\n : {\n url: link.url,\n ...(link.title != null && { title: link.title }),\n ...(link.description != null && { description: link.description }),\n }\n\nconst toMapResponse = (wire: WireMapResponse): MapResponse => ({\n links: wire.links.map(toMapLink),\n raw: wire,\n})\n\n// ---------------------------------------------------------------------------\n// HTTP\n// ---------------------------------------------------------------------------\n\nconst transportFailure = (cause: unknown): AiError.AiError =>\n new AiError.Unavailable({ provider: \"firecrawl\", raw: cause })\n\n/**\n * The call arrived and the answer is unusable — a scrape Firecrawl itself\n * reports as failed, or a body that does not match what this provider knows how\n * to read.\n *\n * Deliberately not `Unavailable`: that tag is retryable, and neither of these\n * gets better on a second attempt. A blocked page stays blocked while the\n * backoff spends the scrape budget on it, and a changed API shape is a code\n * problem, not a weather problem.\n */\nconst unusableResponse = (message: string, raw: unknown): AiError.AiError =>\n new AiError.GenerationFailed({ provider: \"firecrawl\", message, raw })\n\nconst httpStatusError = (status: number, body: string): AiError.AiError => {\n const provider = \"firecrawl\"\n const raw = body\n if (status === 429) return new AiError.RateLimited({ provider, raw })\n if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })\n if (status === 401) return new AiError.AuthFailed({ provider, subtype: \"auth\", raw })\n if (status === 403) return new AiError.AuthFailed({ provider, subtype: \"permission\", raw })\n if (status === 402) return new AiError.AuthFailed({ provider, subtype: \"billing\", raw })\n if (status >= 500) return new AiError.Unavailable({ provider, status, raw })\n return new AiError.InvalidRequest({ provider, raw })\n}\n\nconst baseUrl = (cfg: Config): string => cfg.baseUrl ?? \"https://api.firecrawl.dev\"\n\nconst postScrape = (\n cfg: Config,\n body: WireBody,\n): Effect.Effect<WireResponse, AiError.AiError, HttpClient.HttpClient> =>\n Effect.gen(function* () {\n const client = yield* HttpClient.HttpClient\n const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/v2/scrape`).pipe(\n HttpClientRequest.setHeader(\"Authorization\", `Bearer ${Redacted.value(cfg.apiKey)}`),\n HttpClientRequest.bodyJsonUnsafe(body),\n )\n const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))\n if (response.status >= 400) {\n const text = yield* response.text.pipe(Effect.orElseSucceed(() => \"\"))\n return yield* httpStatusError(response.status, text)\n }\n const json = yield* response.json.pipe(Effect.mapError(transportFailure))\n return yield* Schema.decodeUnknownEffect(WireResponse)(json).pipe(\n Effect.mapError((issue) =>\n unusableResponse(\"firecrawl returned a body this provider cannot read\", issue),\n ),\n )\n })\n\nconst readImpl =\n (cfg: Config) =>\n (\n request: FirecrawlReadRequest,\n ): Effect.Effect<ReadResponse, AiError.AiError, HttpClient.HttpClient> =>\n Effect.flatMap(postScrape(cfg, buildBody(request)), (wire) =>\n // A 200 carrying `success: false` is Firecrawl reporting the page could\n // not be scraped — blocked, JS-only, or timed out. Its own message is the\n // useful part, so it becomes the error rather than a decode failure.\n wire.data === undefined || wire.success === false\n ? Effect.fail(\n unusableResponse(wire.error ?? `firecrawl could not scrape ${request.url}`, wire),\n )\n : Effect.succeed(toResponse(wire, wire.data, request)),\n )\n\nconst postMap = (\n cfg: Config,\n body: WireMapBody,\n): Effect.Effect<WireMapResponse, AiError.AiError, HttpClient.HttpClient> =>\n Effect.gen(function* () {\n const client = yield* HttpClient.HttpClient\n const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/v2/map`).pipe(\n HttpClientRequest.setHeader(\"Authorization\", `Bearer ${Redacted.value(cfg.apiKey)}`),\n HttpClientRequest.bodyJsonUnsafe(body),\n )\n const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))\n if (response.status >= 400) {\n const text = yield* response.text.pipe(Effect.orElseSucceed(() => \"\"))\n return yield* httpStatusError(response.status, text)\n }\n const json = yield* response.json.pipe(Effect.mapError(transportFailure))\n return yield* Schema.decodeUnknownEffect(WireMapResponse)(json).pipe(\n Effect.mapError(transportFailure),\n )\n })\n\nconst mapImpl =\n (cfg: Config) =>\n (\n request: FirecrawlMapRequest,\n ): Effect.Effect<MapResponse, AiError.AiError, HttpClient.HttpClient> =>\n Effect.map(postMap(cfg, buildMapBody(request)), toMapResponse)\n\n// ---------------------------------------------------------------------------\n// Constructors\n// ---------------------------------------------------------------------------\n\n/**\n * Build a `FirecrawlReadService` value. For Layer-based setup, prefer\n * {@link layer}.\n */\nexport const make = (\n cfg: Config,\n): Effect.Effect<FirecrawlReadService, never, HttpClient.HttpClient> =>\n Effect.map(HttpClient.HttpClient, (client) => ({\n read: (request) =>\n readImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),\n map: (request) =>\n mapImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),\n }))\n\n/**\n * Layer registering both the provider-typed `FirecrawlRead` tag and the\n * generic `WebRead` tag over one implementation. A `CommonReadRequest` is\n * structurally a `FirecrawlReadRequest` with the extra knobs unset, so the\n * generic registration forwards directly.\n */\nexport const layer = (\n cfg: Config,\n): Layer.Layer<FirecrawlRead | WebRead, never, HttpClient.HttpClient> => {\n const typed = Layer.effect(FirecrawlRead, make(cfg))\n const generic = Layer.effect(\n WebRead,\n Effect.map(make(cfg), (s): WebReadService => ({ read: (request) => s.read(request) })),\n )\n return Layer.merge(typed, generic)\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/firecrawl",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Firecrawl provider for @effect-uai/core (web read).",
5
5
  "keywords": [
6
6
  "ai",
@@ -43,13 +43,13 @@
43
43
  "access": "public"
44
44
  },
45
45
  "devDependencies": {
46
- "effect": "4.0.0-beta.57",
46
+ "effect": "4.0.0-beta.94",
47
47
  "typescript": "^6.0.3",
48
- "@effect-uai/core": "0.10.0"
48
+ "@effect-uai/core": "0.12.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@effect-uai/core": ">=0.2.0 <1",
52
- "effect": "4.0.0-beta.57"
52
+ "effect": ">=4.0.0-beta.94 <5.0.0"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "tsdown",
@@ -33,8 +33,45 @@ export type FirecrawlReadRequest = CommonReadRequest & {
33
33
  readonly proxy?: FirecrawlProxy
34
34
  }
35
35
 
36
+ /**
37
+ * Firecrawl-typed map (sitemap / URL discovery) request. Given a base `url`,
38
+ * Firecrawl returns the site's URLs without scraping each one - handy for
39
+ * locating specific pages (pricing, docs) before a targeted read. All fields
40
+ * but `url` are optional.
41
+ */
42
+ export type FirecrawlMapRequest = {
43
+ readonly url: string
44
+ /** Order results by relevance to this query. */
45
+ readonly search?: string
46
+ /** Cap the number of links returned. */
47
+ readonly limit?: number
48
+ /** How to use the site's sitemap: `only` (sitemap alone), `include`, or `skip`. */
49
+ readonly sitemap?: "only" | "include" | "skip"
50
+ /** Include links on subdomains of the base URL. */
51
+ readonly includeSubdomains?: boolean
52
+ /** Abort the map if it runs past this duration. */
53
+ readonly timeout?: Duration.Duration
54
+ }
55
+
56
+ /** One discovered URL, with page metadata where Firecrawl has it. */
57
+ export type MapLink = {
58
+ readonly url: string
59
+ readonly title?: string
60
+ readonly description?: string
61
+ }
62
+
63
+ export type MapResponse = {
64
+ readonly links: ReadonlyArray<MapLink>
65
+ readonly raw: unknown
66
+ }
67
+
36
68
  export type FirecrawlReadService = {
37
69
  readonly read: (request: FirecrawlReadRequest) => Effect.Effect<ReadResponse, AiError.AiError>
70
+ /**
71
+ * Discover a site's URLs (sitemap-style) without scraping. Firecrawl-specific;
72
+ * not on the generic `WebRead` tag, whose contract is a single-URL read.
73
+ */
74
+ readonly map: (request: FirecrawlMapRequest) => Effect.Effect<MapResponse, AiError.AiError>
38
75
  }
39
76
 
40
77
  /**
@@ -85,6 +122,24 @@ const buildBody = (request: FirecrawlReadRequest): WireBody => {
85
122
  }
86
123
  }
87
124
 
125
+ type WireMapBody = {
126
+ readonly url: string
127
+ readonly search?: string
128
+ readonly limit?: number
129
+ readonly sitemap?: string
130
+ readonly includeSubdomains?: boolean
131
+ readonly timeout?: number
132
+ }
133
+
134
+ const buildMapBody = (request: FirecrawlMapRequest): WireMapBody => ({
135
+ url: request.url,
136
+ ...(request.search !== undefined && { search: request.search }),
137
+ ...(request.limit !== undefined && { limit: request.limit }),
138
+ ...(request.sitemap !== undefined && { sitemap: request.sitemap }),
139
+ ...(request.includeSubdomains !== undefined && { includeSubdomains: request.includeSubdomains }),
140
+ ...(request.timeout !== undefined && { timeout: Duration.toMillis(request.timeout) }),
141
+ })
142
+
88
143
  // ---------------------------------------------------------------------------
89
144
  // Codec - response
90
145
  // ---------------------------------------------------------------------------
@@ -105,17 +160,28 @@ const WireData = Schema.Struct({
105
160
  metadata: Schema.optional(WireMetadata),
106
161
  })
107
162
 
163
+ /**
164
+ * `data` is optional because a failed scrape is not an HTTP failure. Firecrawl
165
+ * answers 200 with `{ success: false, error }` when a page is blocked, times
166
+ * out or renders only under JS — so requiring `data` turned every one of those
167
+ * into a decode error, which was then reported as a transport fault and
168
+ * retried on the backoff schedule.
169
+ */
108
170
  const WireResponse = Schema.Struct({
109
171
  success: Schema.optional(Schema.Boolean),
110
- data: WireData,
172
+ data: Schema.optional(WireData),
173
+ error: Schema.optional(Schema.NullOr(Schema.String)),
111
174
  })
112
175
  type WireResponse = typeof WireResponse.Type
113
176
 
114
177
  const pickTitle = (title: string | ReadonlyArray<string> | null | undefined): string | undefined =>
115
178
  typeof title === "string" ? title : Array.isArray(title) ? title[0] : undefined
116
179
 
117
- const toResponse = (wire: WireResponse, request: FirecrawlReadRequest): ReadResponse => {
118
- const { data } = wire
180
+ const toResponse = (
181
+ wire: WireResponse,
182
+ data: NonNullable<WireResponse["data"]>,
183
+ request: FirecrawlReadRequest,
184
+ ): ReadResponse => {
119
185
  const format = request.format ?? "markdown"
120
186
  const content = (format === "html" ? data.html : data.markdown) ?? ""
121
187
  const title = pickTitle(data.metadata?.title)
@@ -129,6 +195,34 @@ const toResponse = (wire: WireResponse, request: FirecrawlReadRequest): ReadResp
129
195
  }
130
196
  }
131
197
 
198
+ // v2 returns link objects; older responses returned bare URL strings. Accept
199
+ // both and normalize a string to `{ url }` in `toMapResponse`.
200
+ const WireMapLink = Schema.Struct({
201
+ url: Schema.String,
202
+ title: Schema.optional(Schema.NullOr(Schema.String)),
203
+ description: Schema.optional(Schema.NullOr(Schema.String)),
204
+ })
205
+
206
+ const WireMapResponse = Schema.Struct({
207
+ success: Schema.optional(Schema.Boolean),
208
+ links: Schema.Array(Schema.Union([WireMapLink, Schema.String])),
209
+ })
210
+ type WireMapResponse = typeof WireMapResponse.Type
211
+
212
+ const toMapLink = (link: typeof WireMapLink.Type | string): MapLink =>
213
+ typeof link === "string"
214
+ ? { url: link }
215
+ : {
216
+ url: link.url,
217
+ ...(link.title != null && { title: link.title }),
218
+ ...(link.description != null && { description: link.description }),
219
+ }
220
+
221
+ const toMapResponse = (wire: WireMapResponse): MapResponse => ({
222
+ links: wire.links.map(toMapLink),
223
+ raw: wire,
224
+ })
225
+
132
226
  // ---------------------------------------------------------------------------
133
227
  // HTTP
134
228
  // ---------------------------------------------------------------------------
@@ -136,6 +230,19 @@ const toResponse = (wire: WireResponse, request: FirecrawlReadRequest): ReadResp
136
230
  const transportFailure = (cause: unknown): AiError.AiError =>
137
231
  new AiError.Unavailable({ provider: "firecrawl", raw: cause })
138
232
 
233
+ /**
234
+ * The call arrived and the answer is unusable — a scrape Firecrawl itself
235
+ * reports as failed, or a body that does not match what this provider knows how
236
+ * to read.
237
+ *
238
+ * Deliberately not `Unavailable`: that tag is retryable, and neither of these
239
+ * gets better on a second attempt. A blocked page stays blocked while the
240
+ * backoff spends the scrape budget on it, and a changed API shape is a code
241
+ * problem, not a weather problem.
242
+ */
243
+ const unusableResponse = (message: string, raw: unknown): AiError.AiError =>
244
+ new AiError.GenerationFailed({ provider: "firecrawl", message, raw })
245
+
139
246
  const httpStatusError = (status: number, body: string): AiError.AiError => {
140
247
  const provider = "firecrawl"
141
248
  const raw = body
@@ -167,7 +274,9 @@ const postScrape = (
167
274
  }
168
275
  const json = yield* response.json.pipe(Effect.mapError(transportFailure))
169
276
  return yield* Schema.decodeUnknownEffect(WireResponse)(json).pipe(
170
- Effect.mapError(transportFailure),
277
+ Effect.mapError((issue) =>
278
+ unusableResponse("firecrawl returned a body this provider cannot read", issue),
279
+ ),
171
280
  )
172
281
  })
173
282
 
@@ -176,7 +285,44 @@ const readImpl =
176
285
  (
177
286
  request: FirecrawlReadRequest,
178
287
  ): Effect.Effect<ReadResponse, AiError.AiError, HttpClient.HttpClient> =>
179
- Effect.map(postScrape(cfg, buildBody(request)), (wire) => toResponse(wire, request))
288
+ Effect.flatMap(postScrape(cfg, buildBody(request)), (wire) =>
289
+ // A 200 carrying `success: false` is Firecrawl reporting the page could
290
+ // not be scraped — blocked, JS-only, or timed out. Its own message is the
291
+ // useful part, so it becomes the error rather than a decode failure.
292
+ wire.data === undefined || wire.success === false
293
+ ? Effect.fail(
294
+ unusableResponse(wire.error ?? `firecrawl could not scrape ${request.url}`, wire),
295
+ )
296
+ : Effect.succeed(toResponse(wire, wire.data, request)),
297
+ )
298
+
299
+ const postMap = (
300
+ cfg: Config,
301
+ body: WireMapBody,
302
+ ): Effect.Effect<WireMapResponse, AiError.AiError, HttpClient.HttpClient> =>
303
+ Effect.gen(function* () {
304
+ const client = yield* HttpClient.HttpClient
305
+ const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/v2/map`).pipe(
306
+ HttpClientRequest.setHeader("Authorization", `Bearer ${Redacted.value(cfg.apiKey)}`),
307
+ HttpClientRequest.bodyJsonUnsafe(body),
308
+ )
309
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
310
+ if (response.status >= 400) {
311
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
312
+ return yield* httpStatusError(response.status, text)
313
+ }
314
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
315
+ return yield* Schema.decodeUnknownEffect(WireMapResponse)(json).pipe(
316
+ Effect.mapError(transportFailure),
317
+ )
318
+ })
319
+
320
+ const mapImpl =
321
+ (cfg: Config) =>
322
+ (
323
+ request: FirecrawlMapRequest,
324
+ ): Effect.Effect<MapResponse, AiError.AiError, HttpClient.HttpClient> =>
325
+ Effect.map(postMap(cfg, buildMapBody(request)), toMapResponse)
180
326
 
181
327
  // ---------------------------------------------------------------------------
182
328
  // Constructors
@@ -189,9 +335,11 @@ const readImpl =
189
335
  export const make = (
190
336
  cfg: Config,
191
337
  ): Effect.Effect<FirecrawlReadService, never, HttpClient.HttpClient> =>
192
- Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
338
+ Effect.map(HttpClient.HttpClient, (client) => ({
193
339
  read: (request) =>
194
340
  readImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
341
+ map: (request) =>
342
+ mapImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
195
343
  }))
196
344
 
197
345
  /**