@takosjp/yurucommu-core 4.0.0 → 4.1.1

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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Which runtime the Worker was published onto, and the bindings that follow.
3
+ *
4
+ * The same bundle runs on two backends that look identical from inside:
5
+ *
6
+ * `cloudflare` — RAW Cloudflare bindings. `env.DB` is a `D1Database`,
7
+ * `env.KV` a `KVNamespace`, `env.MEDIA` an `R2Bucket`,
8
+ * `env.DELIVERY_QUEUE` a `Queue`. This is a Worker deployed
9
+ * straight to Cloudflare, and equally an ordinary-Workers
10
+ * Takoserver backend, which projects those same raw bindings.
11
+ * `portable` — the PORTABLE FACADES. A wrapper host — a self-hosted
12
+ * Takoserver, or a managed Workers-for-Platforms backend —
13
+ * replaces `env` before the module sees it, and each binding
14
+ * is the facade its Interface names: `edge.sql`, `edge.kv`,
15
+ * `edge.objects`, `edge.queue`.
16
+ *
17
+ * THE LANE NAMES THE BINDING SHAPE, not the tool that published the Worker. A
18
+ * deployment authored in Takoform lands on either one depending on the host it
19
+ * targets, so the lane cannot be inferred from the IaC that produced it.
20
+ *
21
+ * THE LANE IS DECLARED, NOT SNIFFED. Two of the bindings cannot be told apart
22
+ * by shape at all — `edge.kv` and `KVNamespace` expose the same five method
23
+ * names, and both queue producers are `send`/`sendBatch`. A Worker that guessed
24
+ * would call `kv.get(key, {type:"json"})` on a facade that ignores the second
25
+ * argument and returns bytes, and the failure would surface much later as a
26
+ * corrupt session or a rate-limit that never trips.
27
+ *
28
+ * So the lane comes from `YURUCOMMU_RUNTIME_LANE`, which a self-host or managed
29
+ * Workers-for-Platforms deployment sets to `portable` and every raw-binding
30
+ * deployment leaves unset (or `cloudflare`). The declaration is then
31
+ * cross-checked against the ONE binding that is decisive — `DB`. A
32
+ * disagreement refuses to start.
33
+ *
34
+ * `MEDIA` is NOT decisive and must never be cross-checked. Takoserver's
35
+ * `edge.objects@1.0.0` facade is method-for-method a bucket: `head`, `get`,
36
+ * `put`, `delete`, `list`, `createMultipartUpload`, `uploadPart`,
37
+ * `completeMultipartUpload`, `abortMultipartUpload` — the same names, the same
38
+ * option keys, deliberately, so that an app written against R2 ports over
39
+ * unchanged (ADR 0005/0007). 4.1.0 read that identity backwards and refused the
40
+ * portable lane whenever `MEDIA` looked R2-shaped, which is to say always: a
41
+ * self-hosted Yurucommu Worker could not boot on the lane its own README
42
+ * documents. The declaration decides `MEDIA`.
43
+ */
44
+
45
+ import type {
46
+ D1Database,
47
+ Fetcher,
48
+ KVNamespace,
49
+ MessageBatch,
50
+ Queue,
51
+ R2Bucket,
52
+ } from "@cloudflare/workers-types";
53
+
54
+ import type { Database } from "../../db/index.ts";
55
+ import {
56
+ isEdgeObjectsBinding,
57
+ isEdgeQueueBatch,
58
+ isEdgeSqlBinding,
59
+ isNativeD1Database,
60
+ type EdgeKvBinding,
61
+ type EdgeObjectsBinding,
62
+ type EdgeQueueBatch,
63
+ type EdgeQueueBinding,
64
+ type EdgeSqlBinding,
65
+ } from "./edge-facades.ts";
66
+ import { createEdgeSqlDatabase } from "./edge-sql.ts";
67
+ import { wrapEdgeKv } from "./edge-kv.ts";
68
+ import { wrapEdgeMessageBatch, wrapEdgeQueue } from "./edge-queue.ts";
69
+ import { wrapEdgeObjects } from "./edge-objects.ts";
70
+ import {
71
+ wrapCloudflareBindings,
72
+ wrapCloudflareMessageBatch,
73
+ } from "./cloudflare.ts";
74
+ import type { IKeyValueStore, IStaticAssets, ObjectStore } from "./types.ts";
75
+ import type { IQueueBatch, IQueueProducer } from "./queue.ts";
76
+
77
+ /** The variable that names the lane. Set it in the deployment's plain vars. */
78
+ export const RUNTIME_LANE_VAR = "YURUCOMMU_RUNTIME_LANE";
79
+
80
+ /** Every lane this build knows how to run on. */
81
+ export const RUNTIME_LANES = ["cloudflare", "portable"] as const;
82
+
83
+ export type RuntimeLane = (typeof RUNTIME_LANES)[number];
84
+
85
+ /** The lane when the variable is absent: a plain Cloudflare Worker. */
86
+ export const DEFAULT_RUNTIME_LANE: RuntimeLane = "cloudflare";
87
+
88
+ /** The declared lane is unknown, or disagrees with the bindings that arrived. */
89
+ export class RuntimeLaneError extends Error {
90
+ constructor(message: string) {
91
+ super(message);
92
+ this.name = "RuntimeLaneError";
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Read the declared lane.
98
+ *
99
+ * An unset variable is the Cloudflare lane, because that is what a Worker
100
+ * deployed without Takoform is. An UNRECOGNISED value is refused rather than
101
+ * defaulted: a future Host that names a lane this build has never heard of must
102
+ * not be served by guessing that its bindings are Cloudflare's.
103
+ */
104
+ export function resolveRuntimeLane(declared: unknown): RuntimeLane {
105
+ if (declared === undefined || declared === null || declared === "") {
106
+ return DEFAULT_RUNTIME_LANE;
107
+ }
108
+ if (typeof declared !== "string") {
109
+ throw new RuntimeLaneError(
110
+ `${RUNTIME_LANE_VAR} must be a string; received ${typeof declared}`,
111
+ );
112
+ }
113
+ const lane = declared.trim();
114
+ if ((RUNTIME_LANES as readonly string[]).includes(lane)) {
115
+ return lane as RuntimeLane;
116
+ }
117
+ throw new RuntimeLaneError(
118
+ `${RUNTIME_LANE_VAR}="${declared}" is not a runtime lane this build ` +
119
+ `supports (${RUNTIME_LANES.join(", ")}). Refusing to start rather than ` +
120
+ `assume a binding shape.`,
121
+ );
122
+ }
123
+
124
+ interface LaneBindings {
125
+ readonly DB?: unknown;
126
+ /**
127
+ * Accepted so a caller can pass the whole `env`, and deliberately not read:
128
+ * the bucket binding carries no evidence about the lane. See
129
+ * {@link assertRuntimeLaneBindings}.
130
+ */
131
+ readonly MEDIA?: unknown;
132
+ }
133
+
134
+ /**
135
+ * Prove the declared lane against the ONE binding that can be identified.
136
+ *
137
+ * `DB` is decisive in both directions: `execute`/`query`/`transaction` and
138
+ * `prepare`/`batch` are disjoint method sets, so a Worker that was handed the
139
+ * wrong one would fail at its first query anyway and is better stopped here
140
+ * with a message that names the variable to fix.
141
+ *
142
+ * `MEDIA` is checked against NOTHING. The portable `edge.objects@1.0.0` facade
143
+ * is intentionally indistinguishable from an `R2Bucket` — that identity is the
144
+ * point of the Interface — so a shape test on it can only produce false
145
+ * refusals. On `portable` the bucket is wrapped as the facade, on `cloudflare`
146
+ * as native R2, and the declaration is the whole of the evidence. Getting it
147
+ * wrong is loud and immediate (the first `MEDIA` call throws), not the silent
148
+ * misread that `KV`'s ambiguity would cause.
149
+ */
150
+ export function assertRuntimeLaneBindings(
151
+ lane: RuntimeLane,
152
+ bindings: LaneBindings,
153
+ ): void {
154
+ const { DB } = bindings;
155
+ if (lane === "portable") {
156
+ if (isNativeD1Database(DB)) {
157
+ throw new RuntimeLaneError(
158
+ `${RUNTIME_LANE_VAR}="portable" declares the portable-facade lane, ` +
159
+ `but env.DB is a native D1Database (prepare/batch). A host that ` +
160
+ `projects raw Cloudflare bindings — including an ordinary-Workers ` +
161
+ `Takoserver backend — is the cloudflare lane; leave the variable ` +
162
+ `unset there.`,
163
+ );
164
+ }
165
+ if (!isEdgeSqlBinding(DB)) {
166
+ throw new RuntimeLaneError(
167
+ `${RUNTIME_LANE_VAR}="portable" requires env.DB to be the ` +
168
+ `edge.sql@1.0.0 facade (execute/query/transaction); it exposes ` +
169
+ `neither that nor D1's prepare/batch.`,
170
+ );
171
+ }
172
+ return;
173
+ }
174
+ if (isEdgeSqlBinding(DB)) {
175
+ throw new RuntimeLaneError(
176
+ `env.DB is the edge.sql@1.0.0 facade (execute/query/transaction), but ` +
177
+ `${RUNTIME_LANE_VAR} does not declare the portable lane. A Worker on a ` +
178
+ `wrapper host must declare it; without that this build would hand the ` +
179
+ `facade to drizzle-orm/d1 and every query would fail at the first ` +
180
+ `prepare().`,
181
+ );
182
+ }
183
+ if (!isNativeD1Database(DB)) {
184
+ throw new RuntimeLaneError(
185
+ `env.DB is neither a D1Database nor the edge.sql@1.0.0 facade; the ` +
186
+ `Cloudflare lane cannot build a database client from it.`,
187
+ );
188
+ }
189
+ }
190
+
191
+ /** Bindings a Worker receives from a host that projects portable facades. */
192
+ export interface PortableWorkerBindings {
193
+ DB: EdgeSqlBinding;
194
+ KV: EdgeKvBinding;
195
+ MEDIA?: EdgeObjectsBinding;
196
+ ASSETS?: IStaticAssets;
197
+ DELIVERY_QUEUE?: EdgeQueueBinding;
198
+ DELIVERY_DLQ?: EdgeQueueBinding;
199
+ }
200
+
201
+ /** Bindings a Worker deployed straight to Cloudflare receives. */
202
+ export interface CloudflareWorkerBindings {
203
+ DB: D1Database;
204
+ KV: KVNamespace;
205
+ MEDIA?: R2Bucket;
206
+ ASSETS?: Fetcher;
207
+ DELIVERY_QUEUE?: Queue<unknown>;
208
+ DELIVERY_DLQ?: Queue<unknown>;
209
+ }
210
+
211
+ type WrappedRuntime<T> = Omit<
212
+ T,
213
+ "DB" | "MEDIA" | "KV" | "ASSETS" | "DELIVERY_QUEUE" | "DELIVERY_DLQ"
214
+ > & {
215
+ DB_INSTANCE: Database;
216
+ MEDIA?: ObjectStore;
217
+ KV: IKeyValueStore;
218
+ ASSETS?: IStaticAssets;
219
+ DELIVERY_QUEUE?: IQueueProducer<unknown>;
220
+ DELIVERY_DLQ?: IQueueProducer<unknown>;
221
+ };
222
+
223
+ /**
224
+ * Wrap the portable facades into the runtime ports the app speaks.
225
+ *
226
+ * `ASSETS` passes through: a Takoform `external_services` entry is projected as
227
+ * a `{fetch}` adapter, which is already the port's whole surface.
228
+ */
229
+ export function wrapPortableBindings<T extends PortableWorkerBindings>(
230
+ bindings: T,
231
+ ): WrappedRuntime<T> {
232
+ const { DB, MEDIA, KV, ASSETS, DELIVERY_QUEUE, DELIVERY_DLQ, ...rest } =
233
+ bindings;
234
+ return {
235
+ ...rest,
236
+ DB_INSTANCE: createEdgeSqlDatabase(DB),
237
+ MEDIA: MEDIA ? wrapEdgeObjects(MEDIA) : undefined,
238
+ KV: wrapEdgeKv(KV),
239
+ ASSETS,
240
+ DELIVERY_QUEUE: DELIVERY_QUEUE ? wrapEdgeQueue(DELIVERY_QUEUE) : undefined,
241
+ DELIVERY_DLQ: DELIVERY_DLQ ? wrapEdgeQueue(DELIVERY_DLQ) : undefined,
242
+ } as unknown as WrappedRuntime<T>;
243
+ }
244
+
245
+ /**
246
+ * The single entry point a Worker should call.
247
+ *
248
+ * Reads {@link RUNTIME_LANE_VAR} off the bindings themselves — on both lanes it
249
+ * is an ordinary plain-text variable that arrives alongside them — proves the
250
+ * lane against the decisive bindings, and then wraps.
251
+ */
252
+ export function wrapRuntimeBindings<
253
+ // Deliberately structural. Which of the two binding sets this actually is,
254
+ // is the runtime question this function answers; a static union here would
255
+ // only force every caller to assert the answer before asking it.
256
+ T extends { DB: unknown; KV: unknown },
257
+ >(bindings: T): WrappedRuntime<T> {
258
+ const lane = resolveRuntimeLane(
259
+ (bindings as Record<string, unknown>)[RUNTIME_LANE_VAR],
260
+ );
261
+ assertRuntimeLaneBindings(lane, bindings as LaneBindings);
262
+ return lane === "portable"
263
+ ? (wrapPortableBindings(
264
+ bindings as unknown as PortableWorkerBindings,
265
+ ) as unknown as WrappedRuntime<T>)
266
+ : (wrapCloudflareBindings(
267
+ bindings as unknown as CloudflareWorkerBindings & {
268
+ DB: D1Database;
269
+ KV: KVNamespace;
270
+ },
271
+ ) as unknown as WrappedRuntime<T>);
272
+ }
273
+
274
+ /**
275
+ * Adapt one consumer batch for whichever lane produced it.
276
+ *
277
+ * A queue batch IS decisive — the facade settles with `acknowledgeAll`, the
278
+ * Cloudflare `MessageBatch` with `ackAll` — so the shape is checked against the
279
+ * declared lane rather than trusted on its own.
280
+ */
281
+ export function wrapRuntimeMessageBatch<T>(
282
+ batch: MessageBatch<T> | EdgeQueueBatch,
283
+ lane: RuntimeLane = DEFAULT_RUNTIME_LANE,
284
+ ): IQueueBatch<T> {
285
+ const isFacade = isEdgeQueueBatch(batch);
286
+ if (lane === "portable") {
287
+ if (!isFacade) {
288
+ throw new RuntimeLaneError(
289
+ `${RUNTIME_LANE_VAR}="portable" declares the portable-facade lane, ` +
290
+ `but the queue event is a Cloudflare MessageBatch (ackAll).`,
291
+ );
292
+ }
293
+ return wrapEdgeMessageBatch<T>(batch);
294
+ }
295
+ if (isFacade) {
296
+ throw new RuntimeLaneError(
297
+ `The queue event is a portable-facade batch (acknowledgeAll), but ` +
298
+ `${RUNTIME_LANE_VAR} does not declare the portable lane.`,
299
+ );
300
+ }
301
+ return wrapCloudflareMessageBatch(batch as MessageBatch<T>);
302
+ }
303
+
304
+ /**
305
+ * Re-exported so a Worker entry can assert that SOMETHING bucket-shaped
306
+ * arrived without importing internals. It does not identify the lane — a
307
+ * native `R2Bucket` satisfies it too — so never branch on it.
308
+ */
309
+ export { isEdgeObjectsBinding };
@@ -5,6 +5,7 @@ import {
5
5
  parseManagedRelationalBatchResponse,
6
6
  type ManagedRelationalMethod,
7
7
  type ManagedRelationalParameter,
8
+ type ManagedRelationalStatement,
8
9
  } from "@takosjp/takosumi-contract/managed-relational-runtime";
9
10
  import { parseManagedRuntimeConnectionMaterialization } from "@takosjp/takosumi-contract/managed-runtime-connections";
10
11
  import {
@@ -16,9 +17,17 @@ import {
16
17
  import * as schema from "../../db/schema.ts";
17
18
  import { ManagedRuntimeGatewayError } from "./managed-runtime.ts";
18
19
  import type { ManagedRuntimeGateway } from "./managed-runtime.ts";
20
+ import {
21
+ positionalRow,
22
+ rewriteProjection,
23
+ type ProjectedStatement,
24
+ } from "./sqlite-proxy-rows.ts";
19
25
 
20
26
  const DEFAULT_MAX_RELATIONAL_RESPONSE_BYTES = 8 * 1024 * 1024;
21
27
 
28
+ /** The lane name a row-shape refusal reports. */
29
+ const LANE = "managed relational";
30
+
22
31
  export interface ManagedRelationalDatabaseOptions {
23
32
  readonly materialization: unknown;
24
33
  readonly gateway: ManagedRuntimeGateway;
@@ -33,6 +42,11 @@ export interface ManagedRelationalDatabaseOptions {
33
42
  * Every callback is one bounded prepared statement. Drizzle `batch()` maps to
34
43
  * one ordered-atomic host call; transaction-control and migration SQL are
35
44
  * deliberately unavailable on this request path.
45
+ *
46
+ * Row shape is shared with the `edge.sql` lane (`sqlite-proxy-rows.ts`): the
47
+ * projection list is rewritten so no two result columns share a name, the
48
+ * column count that comes back is checked against the count that went out, and
49
+ * the row Drizzle indexes positionally also answers to its column names.
36
50
  */
37
51
  export function createManagedRelationalDatabase(
38
52
  options: ManagedRelationalDatabaseOptions,
@@ -64,13 +78,9 @@ export function createManagedRelationalDatabase(
64
78
  readonly method: ManagedRelationalMethod;
65
79
  }[],
66
80
  ) => {
67
- const canonical = statements.map((statement) => ({
68
- sql: statement.sql,
69
- params: statement.params.map(relationalParameter),
70
- method: statement.method,
71
- }));
81
+ const prepared = statements.map(prepare);
72
82
  const request = managedRelationalBatchGatewayRequest(connection.authority, {
73
- statements: canonical,
83
+ statements: prepared.map((entry) => entry.statement),
74
84
  idempotencyKey: idempotencyKey(),
75
85
  });
76
86
  const response = await boundedResponse(
@@ -91,40 +101,77 @@ export function createManagedRelationalDatabase(
91
101
  }
92
102
  const value = parseManagedRelationalBatchResponse(
93
103
  await response.json(),
94
- canonical.length,
104
+ prepared.length,
105
+ );
106
+ return value.results.map((result, index) =>
107
+ drizzleResult(result, prepared[index]!),
95
108
  );
96
- return value.results;
97
109
  };
98
110
 
99
111
  const callback: AsyncRemoteCallback = async (sql, params, method) => {
100
112
  const [result] = await execute([{ sql, params, method }]);
101
113
  if (!result) throw new Error("managed_relational_result_missing");
102
- return drizzleResult(result, method);
103
- };
104
- const batchCallback: AsyncBatchRemoteCallback = async (batch) => {
105
- const results = await execute(batch);
106
- return results.map((result, index) =>
107
- drizzleResult(result, batch[index]!.method),
108
- );
114
+ return result;
109
115
  };
116
+ const batchCallback: AsyncBatchRemoteCallback = async (batch) =>
117
+ await execute(batch);
110
118
 
111
119
  return drizzleProxy(callback, batchCallback, { schema });
112
120
  }
113
121
 
122
+ interface PreparedStatement {
123
+ readonly statement: ManagedRelationalStatement;
124
+ readonly projection: ProjectedStatement;
125
+ }
126
+
127
+ /**
128
+ * Trim first, then give every projected column a distinct name.
129
+ *
130
+ * The runtime contract refuses a statement whose text is not already trimmed,
131
+ * and Drizzle does not trim the raw `sql` template a call site wrote across
132
+ * several lines — so the personal block and mute gates' own statements would
133
+ * never reach the host at all.
134
+ */
135
+ function prepare(statement: {
136
+ readonly sql: string;
137
+ readonly params: readonly unknown[];
138
+ readonly method: ManagedRelationalMethod;
139
+ }): PreparedStatement {
140
+ const source = statement.sql.trim();
141
+ const rewritten = rewriteProjection(source);
142
+ return {
143
+ statement: {
144
+ sql: rewritten.sql,
145
+ params: statement.params.map(relationalParameter),
146
+ method: statement.method,
147
+ },
148
+ projection: { lane: LANE, sql: source, columns: rewritten.columns },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * `sqlite-proxy` wants a flat row for `get` and an array of rows otherwise.
154
+ *
155
+ * A `get` that matched nothing must yield `undefined`, not an empty array:
156
+ * Drizzle's `mapGetResult` short-circuits on a FALSY row, and `[]` is truthy,
157
+ * so an empty array is mapped into an object whose every field is `undefined` —
158
+ * a "row" for a query that found none, which `if (exact) return true` in the
159
+ * personal block gate reads as a hit.
160
+ */
114
161
  function drizzleResult(
115
162
  result: Awaited<
116
163
  ReturnType<typeof parseManagedRelationalBatchResponse>
117
164
  >["results"][number],
118
- method: ManagedRelationalMethod,
165
+ prepared: PreparedStatement,
119
166
  ) {
167
+ // sqlite-proxy exposes mutable `any[]` at this boundary even though the
168
+ // public runtime contract is intentionally immutable. `positionalRow` copies,
169
+ // so the provider-neutral contract never leaks a mutable result reference.
170
+ const rows = result.rows.map((row) =>
171
+ positionalRow(prepared.projection, result.columns, row),
172
+ );
120
173
  return {
121
- // sqlite-proxy exposes mutable `any[]` at this boundary even though the
122
- // public runtime contract is intentionally immutable. Copy here so the
123
- // provider-neutral contract never leaks a mutable result reference.
124
- rows:
125
- method === "get"
126
- ? [...(result.rows[0] ?? [])]
127
- : result.rows.map((row) => [...row]),
174
+ rows: (prepared.statement.method === "get" ? rows[0] : rows) as unknown[],
128
175
  meta: result.meta,
129
176
  };
130
177
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Filesystem path containment for the Bun/Node runtime ONLY.
3
+ *
4
+ * These helpers need `node:path`, so they live apart from `shared.ts`. That
5
+ * separation is load-bearing rather than tidy: `shared.ts` is reached from
6
+ * `edge-kv.ts` and `edge-objects.ts`, which are on the portable Worker's
7
+ * import path. A `node:` specifier anywhere in that graph survives bundling as
8
+ * a real static import, and a wrapper host (self-hosted Takoserver, managed
9
+ * Workers-for-Platforms) runs the Worker with no `nodejs_compat` flag — the
10
+ * portable `WorkerVersion` form has nowhere to ask for one — so the module
11
+ * fails to load with `No such module "node:path"` before a single request is
12
+ * served. Nothing in this file may be imported from a module the Worker
13
+ * bundle reaches; `scripts/check-worker-bundle-portable.mjs` enforces that.
14
+ */
15
+
16
+ import path from "node:path";
17
+
18
+ import { hasNulByte } from "./shared.ts";
19
+
20
+ export function isPathWithinBasePath(
21
+ basePath: string,
22
+ candidatePath: string,
23
+ ): boolean {
24
+ const relative = path.relative(basePath, candidatePath);
25
+ return (
26
+ relative === "" ||
27
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
28
+ );
29
+ }
30
+
31
+ export function resolvePathWithinBasePath(
32
+ basePath: string,
33
+ key: string,
34
+ ): string {
35
+ if (hasNulByte(key)) {
36
+ throw new Error("Invalid path");
37
+ }
38
+ const resolvedPath = path.resolve(basePath, key);
39
+ if (!isPathWithinBasePath(basePath, resolvedPath)) {
40
+ throw new Error("Path escapes base directory");
41
+ }
42
+ return resolvedPath;
43
+ }
44
+
45
+ function isNotFoundError(error: unknown): boolean {
46
+ return (
47
+ typeof error === "object" &&
48
+ error !== null &&
49
+ "code" in error &&
50
+ (error as { code?: unknown }).code === "ENOENT"
51
+ );
52
+ }
53
+
54
+ export async function assertPathChainWithinBasePath(
55
+ basePath: string,
56
+ targetPath: string,
57
+ realpath: (path: string) => Promise<string>,
58
+ ): Promise<void> {
59
+ let currentPath = targetPath;
60
+
61
+ while (true) {
62
+ try {
63
+ const realCurrentPath = await realpath(currentPath);
64
+ if (!isPathWithinBasePath(basePath, realCurrentPath)) {
65
+ throw new Error("Path escapes base directory");
66
+ }
67
+ return;
68
+ } catch (error) {
69
+ if (!isNotFoundError(error)) {
70
+ throw error;
71
+ }
72
+ const parentPath = path.dirname(currentPath);
73
+ if (parentPath === currentPath) {
74
+ throw error;
75
+ }
76
+ currentPath = parentPath;
77
+ }
78
+ }
79
+ }
@@ -1,4 +1,12 @@
1
- import path from "node:path";
1
+ /**
2
+ * Runtime helpers shared by EVERY lane, including the portable Worker.
3
+ *
4
+ * `edge-kv.ts` and `edge-objects.ts` import this module, so it is part of the
5
+ * bundle a wrapper host loads with no `nodejs_compat` flag. It must therefore
6
+ * stay free of `node:` specifiers; the filesystem path helpers that need
7
+ * `node:path` live in `node-paths.ts`, which only the Bun/Node runtime
8
+ * imports.
9
+ */
2
10
 
3
11
  export const DEFAULT_LIST_LIMIT = 1000;
4
12
 
@@ -35,67 +43,6 @@ export function hasNulByte(value: string): boolean {
35
43
  return value.includes("\0");
36
44
  }
37
45
 
38
- export function isPathWithinBasePath(
39
- basePath: string,
40
- candidatePath: string,
41
- ): boolean {
42
- const relative = path.relative(basePath, candidatePath);
43
- return (
44
- relative === "" ||
45
- (!relative.startsWith("..") && !path.isAbsolute(relative))
46
- );
47
- }
48
-
49
- export function resolvePathWithinBasePath(
50
- basePath: string,
51
- key: string,
52
- ): string {
53
- if (hasNulByte(key)) {
54
- throw new Error("Invalid path");
55
- }
56
- const resolvedPath = path.resolve(basePath, key);
57
- if (!isPathWithinBasePath(basePath, resolvedPath)) {
58
- throw new Error("Path escapes base directory");
59
- }
60
- return resolvedPath;
61
- }
62
-
63
- function isNotFoundError(error: unknown): boolean {
64
- return (
65
- typeof error === "object" &&
66
- error !== null &&
67
- "code" in error &&
68
- (error as { code?: unknown }).code === "ENOENT"
69
- );
70
- }
71
-
72
- export async function assertPathChainWithinBasePath(
73
- basePath: string,
74
- targetPath: string,
75
- realpath: (path: string) => Promise<string>,
76
- ): Promise<void> {
77
- let currentPath = targetPath;
78
-
79
- while (true) {
80
- try {
81
- const realCurrentPath = await realpath(currentPath);
82
- if (!isPathWithinBasePath(basePath, realCurrentPath)) {
83
- throw new Error("Path escapes base directory");
84
- }
85
- return;
86
- } catch (error) {
87
- if (!isNotFoundError(error)) {
88
- throw error;
89
- }
90
- const parentPath = path.dirname(currentPath);
91
- if (parentPath === currentPath) {
92
- throw error;
93
- }
94
- currentPath = parentPath;
95
- }
96
- }
97
- }
98
-
99
46
  export async function readStream(
100
47
  stream: ReadableStream<Uint8Array>,
101
48
  ): Promise<Uint8Array> {