@12-apps/storage

中文文档
2.9.0发布于 2026/10/27MITES Module构建来源已验证近 30 天下载 3.4万

Image uploads that land at YOUR OWN ORIGIN, never at the bucket (12-20). Isomorphic core (format allowlist, upload ceiling, object-key grammar, rendition set, refusal taxonomy, wire schemas), host-mounted backend surface (./server: createApiStorage, the l

原始使用文档
文档目录10 节
  1. The rule the whole package is built around
  2. Subpath exports
  3. The two endpoints
  4. What a stored photo is
  5. The five values with no default
  6. Backend, in full
  7. An imageKey field is not a string field
  8. Frontend, in full
  9. The image pipeline is a port
  10. A second vendor is a config entry

@12-apps/storage

Image uploads that land at your own origin, never at the bucket.

One backend factory, one frontend factory, a driver port with two implementations, and an image-pipeline port so a native dependency stays the host's choice.

Adopting this? This README is the API reference; ADOPTING.md is the step-by-step wiring, including the five required config values and why each of them has no default.

The rule the whole package is built around

The browser POSTs its bytes to the app server. There is no presigned-URL mode, and there must never be one.

A presigned bucket URL makes a working upload depend on the bucket's CORS rules — configuration that lives in neither this repository nor any deploy it runs. A PUT carrying a Content-Type is never a "simple" request, so the browser sends an OPTIONS preflight first; a freshly created bucket answers it with 403 and no Access-Control-Allow-* headers, and the upload is never sent. What makes that failure mode particularly bad:

  • The server sees nothing. No request ever arrives — no log line, no error rate, no 5xx.
  • The browser will not say why. A blocked cross-origin fetch is deliberately indistinguishable from an offline network at the fetch layer.
  • The person who sees it cannot fix it. The message reaches a store owner, about a bucket only an operator can configure.

A second reason it cannot come back: a stored photo is a set of objects the server derives from the bytes, so an upload the server never sees cannot produce one.

Subpath exports

ImportContents
@12-apps/storageIsomorphic core: format allowlist, upload ceiling, object-key grammar, rendition set, refusal taxonomy, URL builders, wire schemas.
@12-apps/storage/servercreateApiStorage(config), the driver port, the local-disk driver, the image-pipeline port with both implementations, the reclaim.
@12-apps/storage/honostorageRouter({ …config, resolveActor }) — a one-call mount. hono is an OPTIONAL peer.
@12-apps/storage/s3createS3Driver(config) for every S3-compatible vendor. @aws-sdk/client-s3 is an OPTIONAL peer, resolved only here.
@12-apps/storage/reactcreateWebStorage({ apiBase }) — the bound picker, the preview, the upload hook, the browser-side re-encode.

The exports map points at ./src directly — no build step is required at runtime.

The two endpoints

POST <mount>/uploads/image      raw bytes in, { data: { imageKey } } out
GET  <mount>/uploads/local/…    the object back, or a redirect to where it lives

The serve route has two behaviours and the driver decides which: a driver that keeps its own bytes implements read and the route streams them; a bucket-backed driver does not, and the route redirects to publicUrl. That used to be an environment flag read inside the route — the same fact in a second place, free to disagree with the driver actually in use.

What a stored photo is

products/<scope>/<uuid>/full.webp        the whole picture — the zoom
products/<scope>/<uuid>/card-320.webp    4:3, a card on a phone
products/<scope>/<uuid>/card-640.webp    4:3, a retina card / a phone hero
products/<scope>/<uuid>/card-1280.webp   4:3, a retina hero on a desktop
products/<scope>/<uuid>/thumb-128.webp   1:1, a 64px row thumbnail
products/<scope>/<uuid>/thumb-256.webp   1:1, the same row at 2×

The key saved on the row names the uncropped member, and the key's shape is what says the crops exist: …/<uuid>/full.<ext> has them, the flat …/<uuid>.<ext> does not. Nothing else records it — no column, and therefore nothing to keep in step across the draft, the change request, the recycle-bin snapshot and the version history a row is copied into.

<scope> is the tenant. It is what makes cross-tenant reclaim a parse rather than a query: a reclaim refuses a key whose scope is not the caller's before consulting any reference probe. A scheme without it can only answer "may this tenant delete this object?" by proving no row of theirs points at it — which is true of every other tenant's objects too.

The five values with no default

createApiStorage requires driver, maxBytes, imagePipeline, unscopedKeys and references. Each safe value is host-specific, so a default would be a decision made by whoever typed the first host and silently inherited by every one after it — and each of these fails quietly, which is the actual argument. A loud wrong default gets fixed on the first deploy; these do not:

ConfigWhat a default would cost
driverA container's ephemeral disk in production, reporting every upload as a success and losing the objects at the next deploy.
maxBytesWhatever number this file happens to carry, in a host whose tool schemas advertise something else.
imagePipelineWhether crops exist at all — discovered from the shape of a key in production.
unscopedKeysWhether one tenant's reclaim can reach another tenant's bytes.
referencesWhether a restored row still has its photo. [] is not neutral: it means reclaim everything immediately.

logger still defaults, and it is the one that legitimately can: a reclaim may only report, never raise, so the worst a defaulted logger costs is silence. references defaulted too, until it didn't — because its default destroys.

Backend, in full

Note the absence of ??. Every value below either comes from the environment and must be there, or is written out. That is deliberate: a process.env.UPLOADS_DIR ?? \${process.cwd()}/.uploads`in this position is exactly the fail-open default the table above refuses to have.UPLOADS_DIRunset in production means uploads succeed, one container holds the object, the other 404s half therequests, and the next deploy discards both — with nothing in any log, becausewriteFile` succeeded every time.

import { createApiStorage } from '@12-apps/storage/server';
import { createLocalDiskDriver, passthroughImagePipeline } from '@12-apps/storage/server';
import { DEFAULT_MAX_UPLOAD_BYTES, STORAGE_PATHS } from '@12-apps/storage';

/** Fail at BOOT for a missing value, rather than at the next deploy. */
function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required — it must be a MOUNTED VOLUME, not a container path.`);
  return value;
}

const storage = createApiStorage({
  driver: createLocalDiskDriver({
    root: requireEnv('UPLOADS_DIR'),
    publicPathPrefix: `/api${STORAGE_PATHS.serve}`,
  }),
  maxBytes: DEFAULT_MAX_UPLOAD_BYTES,
  imagePipeline: passthroughImagePipeline(),
  // 'reject' because this host is fresh: every key it will ever mint is scoped, so
  // a key without a scope can only be someone else's or nobody's. A host with rows
  // that predate the scope segment passes 'accept' — see ADOPTING.md rule 2.
  unscopedKeys: 'reject',
  // Which of YOUR tables can still need an object after the row stopped pointing at
  // it. `[]` says "nothing here copies a key" — write it only if you have checked.
  references: [
    { name: 'live-rows', referenced: (scope, key) => rowPointsAt(scope, key) },
  ],
});

For development the ?? is fine, and only here, because a lost object on a laptop costs nothing:

// DEV ONLY. Never in the block above.
root: process.env.UPLOADS_DIR ?? `${process.cwd()}/.uploads`,

storage carries everything else a host needs:

MemberUse
routesFramework-neutral descriptors. @12-apps/storage/hono mounts them.
limitsWhat this mount enforces — feed a tool schema from it, never from a constant of your own.
schemas.inlineImageA zod schema whose base64 ceiling is maxBytes.
schemas.objectKeyThe schema for an imageKey field. Not z.string() — see below.
storeImage / storeInlineImageThe write path for a host write that carries bytes (an agent's only option).
urls.objectUrl / urls.imageSources / urls.versionedObjectUrlKey → URL, through the active driver.
reclaim.*Reclaiming a replaced object, a superseded set, or keys a failed write just minted.

An imageKey field is not a string field

objectUrl returns an already-absolute value unchanged — a deliberate affordance for a host migrating off a URL column. So a write body that accepts imageKey: z.string() accepts https://tracker.example/p.png, and every buyer loading that storefront page then sends their IP and user-agent to a third party. No upload, no bucket and no driver is involved, so nothing on the storage path can catch it. Use the mount's own schema:

const body = z.object({
  imageKey: storage.schemas.objectKey.nullish(),
  image: storage.schemas.inlineImage.optional(),
}).superRefine(refineImageInput);

Frontend, in full

import { createWebStorage } from '@12-apps/storage/react';

const storage = createWebStorage({ apiBase: '/api' });
// storage.page        a standalone upload surface
// storage.ImageField  the field a form drops in — a key comes out
// storage.useUpload   the primitive, for a bespoke affordance

The image pipeline is a port

Image processing means a native module, and a native module is build surface every consumer would pay for on every deploy whether or not it wants crops. So sharp is neither a dependency nor a peer here: a host that has it passes the module in.

import sharp from 'sharp';
import { createSharpImagePipeline } from '@12-apps/storage/server';

imagePipeline: createSharpImagePipeline({ sharp });

passthroughImagePipeline() is the other choice: the bytes are stored as they arrived, no crops, flat keys — which is exactly what every catalog image was before crops existed.

The magic-number check is not the pipeline's job, and this README used to say it was. It belongs to the WRITER: storeImage verifies the bytes against the declared type as its first statement, before any pipeline sees them. That placement is deliberate, because the two implementations cannot both be trusted with it — passthrough verifies as part of doing nothing, while sharp cannot: "did libvips decode this" and "is this the format that was declared" are different questions, and libvips decodes plenty of formats this allowlist excludes. Putting the check in the writer means no path to storage goes around it, whichever pipeline a host injected and whether the bytes arrived at the endpoint, base64 inside a write, or through a direct storeImage call.

A second vendor is a config entry

createS3Driver covers every S3-compatible store. AWS needs a region; DigitalOcean Spaces, MinIO, R2 and the rest need an endpoint, plus forcePathStyle for the ones that cannot do virtual-hosted addressing. No branch in the upload path changes, and no host code is written.

Nothing in this package reads process.env.

版本与发布记录

@12-apps/storage 共发布 13 个版本,首次发布于 2026/08/13;过去 12 个月发布了 13 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
2.9.0latest2026/10/2711 天前
2.8.02026/08/252 个月前
2.7.02026/08/252 个月前
2.6.12026/08/252 个月前
2.6.02026/08/242 个月前
2.5.02026/08/242 个月前

关于 @12-apps/storage 的常见问题

@12-apps/storage 是什么?

Image uploads that land at YOUR OWN ORIGIN, never at the bucket (12-20). Isomorphic core (format allowlist, upload ceiling, object-key grammar, rendition set, refusal taxonomy, wire schemas), host-mounted backend surface (./server: createApiStorage, the l

如何安装 @12-apps/storage?

在项目目录运行 npm install @12-apps/storage,或使用 pnpm add / yarn add / bun add。

@12-apps/storage 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/12-apps__storage 是否存在。

@12-apps/storage 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@12-apps/storage 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@12-apps/storage 可以商用吗?

@12-apps/storage 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@12-apps/storage 的下载量有多少?

根据 npm 官方下载统计,@12-apps/storage 在 2026/10/05 – 2026/11/03 这 30 天内被下载 33,530 次,日均约 1,118 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@12-apps/storage 还在维护吗?

最新版本 2.9.0 发布于 2026/10/27(11 天前);至今共发布 13 个版本,其中过去 12 个月发布了 13 个;首次发布于 2026/08/13。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。