@takosjp/yurucommu-core 4.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -59,8 +59,9 @@
59
59
  "start": "bun src/backend/server.ts",
60
60
  "dev": "bun src/backend/server.ts",
61
61
  "dev:server": "bun src/backend/server.ts",
62
- "check": "bun run fmt:check && tsc --noEmit && bun run check:no-opentofu-artifacts && bun run test",
62
+ "check": "bun run fmt:check && tsc --noEmit && bun run check:no-opentofu-artifacts && bun run check:worker-bundle && bun run test",
63
63
  "check:no-opentofu-artifacts": "bun scripts/check-no-opentofu-artifacts.mjs",
64
+ "check:worker-bundle": "bun scripts/check-worker-bundle-portable.mjs",
64
65
  "test": "bun run build:api && bun test test/ src/backend/ packages/api/src/ scripts/check-publish-version-discipline.test.ts scripts/publish-package-resumable.test.ts && bun run check:release-contents",
65
66
  "test:backend": "bun test src/backend/",
66
67
  "build": "bun run build:api",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "description": "Typed client SDK and public API contract for yurucommu-server clients.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -24,7 +24,7 @@ import {
24
24
  assertPathChainWithinBasePath,
25
25
  isPathWithinBasePath,
26
26
  resolvePathWithinBasePath,
27
- } from "./shared.ts";
27
+ } from "./node-paths.ts";
28
28
  import { MemoryKV } from "./memory-kv.ts";
29
29
  import { isBackendPath } from "../lib/backend-paths.ts";
30
30
  import { loadBunSqlite } from "./compat-bun/types.ts";
@@ -9,7 +9,7 @@
9
9
  * The managed Cloudflare backend and the self-host backend project the SAME
10
10
  * facade: same methods, same option keys, same error names. Takoserver's
11
11
  * ADR 0005 states this explicitly for object storage, and its self-host wrapper
12
- * repeats it for KV and SQL.
12
+ * repeats it for all four.
13
13
  *
14
14
  * This module is a TYPE MIRROR of that contract plus the structural probes the
15
15
  * lane selector uses. It deliberately contains no behaviour: the adapters that
@@ -21,7 +21,8 @@
21
21
  * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`,
22
22
  * `createQueueAdapter`, `createEdgeObjectsR2Adapter`
23
23
  * takoserver `src/providers/selfhost-worker-wrapper.ts`
24
- * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`
24
+ * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`,
25
+ * `createQueueAdapter`, `createObjectsAdapter`
25
26
  *
26
27
  * Every method rejects with an `Error` whose `name` is the portable error code
27
28
  * (`invalid_key`, `invalid_value`, `value_too_large`, `metadata_too_large`,
@@ -235,22 +236,24 @@ function hasMethods(value: unknown, names: readonly string[]): boolean {
235
236
  /**
236
237
  * Structural probes.
237
238
  *
238
- * Only SOME bindings can be told apart by shape, and the difference matters:
239
+ * Only ONE binding can be told apart by shape, and the difference matters:
239
240
  *
240
241
  * decisive `DB` — `execute`/`query`/`transaction` (facade) against
241
242
  * `prepare`/`batch` (D1). Disjoint method sets.
242
- * decisive `MEDIA` — R2 carries the multipart helpers the facade omits.
243
243
  * decisive a queue *batch* — `acknowledgeAll` (facade) against `ackAll`
244
244
  * (Cloudflare `MessageBatch`).
245
245
  * AMBIGUOUS `KV` — `edge.kv` and `KVNamespace` expose the same five
246
246
  * method names.
247
247
  * AMBIGUOUS a queue *producer* — both are `send`/`sendBatch`.
248
+ * AMBIGUOUS `MEDIA` — `edge.objects@1.0.0` is R2's method set by design,
249
+ * multipart helpers included. See below.
248
250
  *
249
251
  * That is why the lane is a DECLARED variable rather than something sniffed:
250
- * two of the five bindings cannot be identified at all. The declaration is then
251
- * cross-checked against the decisive bindings, so a Worker whose var and whose
252
- * bindings disagree refuses to start instead of calling `kv.get(key, {type})`
253
- * on a facade that would silently treat the options object as nothing.
252
+ * three of the five bindings cannot be identified at all. The declaration is
253
+ * then cross-checked against the decisive bindings, so a Worker whose var and
254
+ * whose bindings disagree refuses to start instead of calling
255
+ * `kv.get(key, {type})` on a facade that would silently treat the options
256
+ * object as nothing.
254
257
  */
255
258
  export function isEdgeSqlBinding(value: unknown): value is EdgeSqlBinding {
256
259
  return (
@@ -274,24 +277,38 @@ export function isEdgeQueueBatch(value: unknown): value is EdgeQueueBatch {
274
277
  );
275
278
  }
276
279
 
280
+ /**
281
+ * A bucket-shaped binding with `edge.objects@1.0.0`'s call signatures.
282
+ *
283
+ * NOT DECISIVE, and never a lane test. The facade is R2's method set on
284
+ * purpose — `head`, `get`, `put`, `delete`, `list`, and the four multipart
285
+ * calls — so that an app written against R2 ports over unchanged. A real
286
+ * `R2Bucket` therefore answers `true` here too. Use it to check that SOMETHING
287
+ * bucket-shaped arrived, never to decide which host projected it: that is what
288
+ * `YURUCOMMU_RUNTIME_LANE` is for.
289
+ *
290
+ * The arity check is the facade's own contract rather than a discriminator:
291
+ * the Host counts `arguments.length`, so `get` takes its options slot even when
292
+ * that slot is `undefined`.
293
+ */
277
294
  export function isEdgeObjectsBinding(
278
295
  value: unknown,
279
296
  ): value is EdgeObjectsBinding {
280
297
  return (
281
298
  hasMethods(value, ["head", "get", "put", "delete", "list"]) &&
282
- // R2 exposes multipart helpers on the binding itself; the facade does not
283
- // give a bucket-shaped object those names.
284
- typeof (value as Record<string, unknown>).createMultipartUpload !==
285
- "function" &&
286
- // Arity is part of the facade's contract and is asserted rather than
287
- // assumed: the Host checks `arguments.length`, so `get` and `list` take
288
- // their options slot even when it is `undefined`. Anything bucket-shaped
289
- // whose `get` takes one argument is some other adapter, not this facade.
290
299
  (value as { get: (...args: unknown[]) => unknown }).get.length === 2
291
300
  );
292
301
  }
293
302
 
294
- /** Cloudflare's `R2Bucket`. */
303
+ /**
304
+ * Cloudflare's `R2Bucket` — and, unavoidably, the `edge.objects` facade.
305
+ *
306
+ * A method-name test cannot separate the two, because Takoserver's facade
307
+ * carries `createMultipartUpload` as well (`selfhost-worker-wrapper.ts`
308
+ * `createObjectsAdapter`). 4.1.0 used this function to refuse the portable
309
+ * lane and so refused every self-hosted deployment. Keep it for describing a
310
+ * binding; do not let it decide a lane.
311
+ */
295
312
  export function isNativeR2Bucket(value: unknown): boolean {
296
313
  return hasMethods(value, [
297
314
  "head",
@@ -21,10 +21,14 @@
21
21
  * - NO ENUMERATION OR HEAD. The port does not carry them, so neither does the
22
22
  * adapter, even though the Host projects both.
23
23
  *
24
- * AVAILABILITY: `edge.objects` is projected by the managed Cloudflare backend
25
- * (`createEdgeObjectsR2Adapter`). The self-host backend projects only
26
- * `edge.kv` and `edge.sql`, so a self-hosted Worker has no object binding and
27
- * the core's existing "object storage unavailable" behaviour applies.
24
+ * AVAILABILITY: BOTH wrapper backends project `edge.objects`. The managed
25
+ * Cloudflare backend does it over provider-private R2
26
+ * (`createEdgeObjectsR2Adapter`); the self-host backend realizes its own object
27
+ * store for a Version's `bucketBindings` and projects the same facade, byte for
28
+ * byte. A Worker on the `portable` lane therefore receives `env.MEDIA` on
29
+ * either host. What still leaves `MEDIA` unbound is a Version that declared no
30
+ * bucket at all, and the core's existing "object storage unavailable" (503)
31
+ * behaviour is what applies then.
28
32
  */
29
33
 
30
34
  import type {
@@ -14,12 +14,12 @@
14
14
  * and the body arrives as `{encoding:"base64", data}`. `retry` also refuses
15
15
  * `delaySeconds: 0`, which Cloudflare accepts as "no delay".
16
16
  *
17
- * AVAILABILITY: the managed Cloudflare backend projects queue bindings; the
18
- * self-host backend projects only `edge.kv` and `edge.sql` today (see
19
- * takoserver `selfhost-worker-wrapper.ts` `projectEnv`). A self-hosted Worker
20
- * therefore has no queue binding at all, and the core's existing behaviour for
21
- * an unbound `DELIVERY_QUEUE` — synchronous fallback delivery, reported by the
22
- * readiness surface — is what applies there.
17
+ * AVAILABILITY: both wrapper backends project queue bindings (see takoserver
18
+ * `selfhost-worker-wrapper.ts` `projectEnv`, whose data-binding kinds are
19
+ * `edge.kv`, `edge.objects`, `edge.queue` and `edge.sql`). What leaves
20
+ * `DELIVERY_QUEUE` unbound is a Version that declared no queue, and the core's
21
+ * existing behaviour for that — synchronous fallback delivery, reported by the
22
+ * readiness surface — is what applies then.
23
23
  */
24
24
 
25
25
  import {
@@ -28,8 +28,18 @@
28
28
  * So the lane comes from `YURUCOMMU_RUNTIME_LANE`, which a self-host or managed
29
29
  * Workers-for-Platforms deployment sets to `portable` and every raw-binding
30
30
  * deployment leaves unset (or `cloudflare`). The declaration is then
31
- * cross-checked against the bindings that ARE decisive — `DB` always, `MEDIA`
32
- * when it is bound. A disagreement refuses to start.
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`.
33
43
  */
34
44
 
35
45
  import type {
@@ -47,7 +57,6 @@ import {
47
57
  isEdgeQueueBatch,
48
58
  isEdgeSqlBinding,
49
59
  isNativeD1Database,
50
- isNativeR2Bucket,
51
60
  type EdgeKvBinding,
52
61
  type EdgeObjectsBinding,
53
62
  type EdgeQueueBatch,
@@ -114,23 +123,35 @@ export function resolveRuntimeLane(declared: unknown): RuntimeLane {
114
123
 
115
124
  interface LaneBindings {
116
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
+ */
117
131
  readonly MEDIA?: unknown;
118
132
  }
119
133
 
120
134
  /**
121
- * Prove the declared lane against the bindings that can actually be identified.
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.
122
141
  *
123
- * `DB` is always decisive: `execute`/`query`/`transaction` and
124
- * `prepare`/`batch` are disjoint. `MEDIA` is decisive only in one direction —
125
- * an `R2Bucket` is recognisable by its multipart helpers, whereas a plain
126
- * five-method object could be the facade or an adapter a host repository
127
- * supplied so only the direction that can be proven is checked.
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.
128
149
  */
129
150
  export function assertRuntimeLaneBindings(
130
151
  lane: RuntimeLane,
131
152
  bindings: LaneBindings,
132
153
  ): void {
133
- const { DB, MEDIA } = bindings;
154
+ const { DB } = bindings;
134
155
  if (lane === "portable") {
135
156
  if (isNativeD1Database(DB)) {
136
157
  throw new RuntimeLaneError(
@@ -148,13 +169,6 @@ export function assertRuntimeLaneBindings(
148
169
  `neither that nor D1's prepare/batch.`,
149
170
  );
150
171
  }
151
- if (MEDIA !== undefined && isNativeR2Bucket(MEDIA)) {
152
- throw new RuntimeLaneError(
153
- `${RUNTIME_LANE_VAR}="portable" declares the portable-facade lane, ` +
154
- `but env.MEDIA is a native R2Bucket. A portable bucket binding ` +
155
- `arrives as the edge.objects@1.0.0 facade.`,
156
- );
157
- }
158
172
  return;
159
173
  }
160
174
  if (isEdgeSqlBinding(DB)) {
@@ -287,5 +301,9 @@ export function wrapRuntimeMessageBatch<T>(
287
301
  return wrapCloudflareMessageBatch(batch as MessageBatch<T>);
288
302
  }
289
303
 
290
- /** Re-exported so a Worker entry can probe MEDIA without importing internals. */
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
+ */
291
309
  export { isEdgeObjectsBinding };
@@ -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> {