Skip to content
djust
Appearance
Mode

Server Functions (@server_function / djust.call())

New in v0.7.0. Call a Python method on a LiveView from client JS and get the return value back as JSON — without triggering a VDOM re-render. This is pure RPC: the view isn't diffed, no assigns are pushed, and no OpenAPI schema is generated. It's the fastest path when the page needs server data but doesn't want the UI to flicker.

Server functions sit alongside two related primitives — pick the one that matches your intent:

DecoratorTransportRe-renderExternal callersPrimary use
@event_handlerWebSocketYesNoUI interactions — click, submit, input
@event_handler(expose_api=True)HTTP (ADR-008)YesYes (OpenAPI)Mobile apps, S2S integrations, AI-agent tools
@server_functionHTTP (v0.7.0)NoNoTypeahead, validation, data fetch w/ no re-render

Quick start

# views.py
from djust import LiveView
from djust.decorators import server_function

class ProductView(LiveView):
    template_name = "product_search.html"
    api_name = "catalog.product"

    @server_function
    def search(self, q: str = "", **kwargs) -> list[dict]:
        hits = Product.objects.filter(name__icontains=q)[:10]
        return [{"id": p.id, "name": p.name, "price": str(p.price)} for p in hits]
<!-- product_search.html -->
<input id="q" type="text" placeholder="Search products…">
<ul id="results"></ul>

<script>
    const q = document.getElementById('q');
    const ul = document.getElementById('results');
    q.addEventListener('input', async (e) => {
        try {
            const hits = await djust.call(
                'catalog.product',       // view slug
                'search',                 // function name
                { q: e.target.value },    // params
            );
            ul.innerHTML = hits.map(h =>
                `<li>${h.name} — $${h.price}</li>`).join('');
        } catch (err) {
            console.error(err.code, err.message);
        }
    });
</script>

The user types, the server returns hits, the DOM updates — all without a WebSocket frame or a VDOM diff. The input loses focus? Never; djust never touches the input's element.


API reference

Python — @server_function

from djust.decorators import server_function

@server_function
def my_fn(self, arg1: int = 0, **kwargs) -> dict:
    ...
  • No arguments today. (@server_function and @server_function() are both accepted for consistency with Python decorator conventions.)
  • The method gets a _djust_decorators["server_function"] metadata entry — this is what the dispatcher looks up.
  • Dual-decoration raises TypeError at import time. Stacking @event_handler + @server_function on the same method is rejected — a function either re-renders the view or returns an RPC result, never both.
  • Stacking with @permission_required: put @server_function OUTERMOST (topmost). Otherwise the metadata is attached to the inner wrapper and the dispatcher cannot see it.
  • Both sync and async def are supported via the same _call_possibly_async helper used by @event_handler.

Client — djust.call()

const result = await djust.call(viewSlug, fnName, params = {});
  • Returns a Promise that resolves to data.result on 2xx.
  • Rejects with an Error on non-2xx whose .message is the server's error.message, with additional attached fields:
    • err.code — the server-returned error kind (see codes below)
    • err.status — the HTTP status
    • err.details — optional object, populated on invalid_params
  • CSRF: reads the hidden [name=csrfmiddlewaretoken] input first, then falls back to the csrftoken cookie.
  • Sends Content-Type: application/json, X-CSRFToken: <token>, X-Requested-With: XMLHttpRequest, credentials: same-origin.

Endpoint

POST /djust/api/call/<view_slug>/<function_name>/

CSRF is required unconditionally — there is no auth-class opt-out. Server functions are intended for same-origin, session-cookie-only in-browser calls; if you need external callers, use @event_handler(expose_api=True) instead.

Request body

The body must be one of the following exact shapes:

  • An empty body (no bytes at all).
  • A JSON object with no keys: {}.
  • A JSON object with exactly one key "params" whose value is a JSON object: {"params": {"q": "chair"}}.

Any other shape — a flat object like {"q": "chair"}, or a wrapped object with sibling keys like {"params": {...}, "extra": 1} — returns 400 invalid_body. This strict shape is deliberate: otherwise a user whose own field happened to be named params would see every sibling key silently dropped. The JS client helper always sends the wrapped form, so this only matters if you're calling the endpoint with curl or from a non-djust client.

Response

Success (2xx):

{"result": <any-json-serializable-value>}

Error (4xx / 5xx):

{"error": "<code>", "message": "<human-readable>", "details": {...}}

details is present only for some codes (notably invalid_params, which echoes expected / provided / type_errors).

Error codes

CodeStatusWhen it fires
unknown_view404view_slug doesn't match any registered api_name
unknown_function404Method function_name doesn't exist on the view
not_a_server_function404Method exists but wasn't decorated with @server_function
unauthenticated401request.user is anonymous (session cookie missing / expired)
login_required401View-level auth (login_required / view @permission_required) denied
csrf_failed403Django CSRF middleware rejected the request
permission_denied403Handler-level @permission_required denied, OR PermissionDenied raised inside the function
invalid_json400Body isn't valid UTF-8 JSON or isn't a top-level object
invalid_body400Body is valid JSON but doesn't match the {"params": {...}} shape
invalid_params400Missing required params, extra params, or type-coercion failed
rate_limited429@rate_limit token bucket drained
mount_failed500mount() / api_mount() raised
function_error500The function body raised an unexpected exception (logged, not leaked)

Security

  • Authenticated by default. Anonymous callers get 401 immediately — there is no way to expose a server function to anonymous users.
  • CSRF always required. No auth-class CSRF-exempt opt-out like ADR-008 offers for S2S callers. Server functions are same-origin, period.
  • View-level auth is honored. login_required-on-the-class and @permission_required-on-the-class both run via check_view_auth() before the function is dispatched.
  • Handler-level @permission_required is honored via check_handler_permission(). Stack it below @server_function.
  • Rate-limit bucket is shared with ADR-008 dispatch. The process- level _rate_buckets OrderedDict is the same structure; the key is (caller, function_name) where caller is user:<pk> when authenticated. The same LRU cap and eviction rules apply.

Type coercion

Server functions reuse validate_handler_params — the same validator @event_handler uses. Coercion rules:

  • Strings are coerced to the method's signature-annotated types (int, float, bool, Decimal, uuid.UUID, datetime.date, datetime.datetime) via the same paths as event handlers.
  • Unknown params (not in the signature, and no **kwargs) → 400 invalid_params with details.provided.
  • Missing required params → 400 invalid_params with details.expected.
  • Failed coercion (e.g. int("abc")) → 400 invalid_params with details.type_errors.
  • Opt out with @server_function(coerce_types=False) to receive strings as-is.

Return values

Return values are JSON-serialized via DjangoJSONEncoder, so these all work out of the box:

  • Primitives: int, float, str, bool, None
  • Containers: list, tuple, dict (string keys)
  • datetime.date, datetime.datetime, datetime.time — ISO 8601
  • decimal.Decimal — as a string (preserves precision)
  • uuid.UUID — as a string
  • Django Model instances implementing __json__() (convention — DjangoJSONEncoder picks it up)

Anything that isn't serializable → 500 function_error. The TypeError is logged server-side with the view slug + function name; the client sees only the generic message. Serialize explicitly in your function body if you need finer control:

@server_function
def get_product(self, id: int) -> dict:
    p = Product.objects.get(pk=id)
    return {"id": p.id, "name": p.name, "price": str(p.price)}

When to use it

Good fit:

  • Typeahead / autocomplete. User types, server returns hits, you update a sibling <ul>. Updating the LiveView's self.hits and letting it re-render would work, but the input may lose focus or the caret may jump on slow devices.
  • Inline validation. "Is this email already registered?" — call a server function on blur, show a hint, never touch the form DOM.
  • Fetch-without-re-render. Pulling fresh data for a chart library or third-party widget that owns its own DOM subtree and doesn't play nice with VDOM diffing.
  • Client-owned state. When the canonical state lives in JS (e.g. a Monaco editor's buffer), server functions fetch derived data without trying to round-trip the client state back into Python.

Not a good fit:

  • Mutations that should reflect in the UI. If the user clicks "mark as read" and the "unread" badge should decrement, stay with @event_handler — you want the re-render.
  • External / AI-agent callers. No OpenAPI schema is generated. Use @event_handler(expose_api=True) for those.
  • Unauthenticated public endpoints. Server functions require a session cookie. Use a Django view.

Comparison: @server_function vs. neighbours

Dimension@server_function@event_handler@event_handler(expose_api=True)
TransportHTTP POSTWebSocket frameHTTP POST
URL/djust/api/call/<slug>/<fn>/(WS)/djust/api/<slug>/<handler>/
Re-renderNoYes (VDOM diff)Yes (VDOM diff + assigns diff)
Response envelope{"result": ...}Patches over the wire{"result": ..., "assigns": {...}}
OpenAPINoNoYes
Anonymous callersNo (401)No (WS auth)Configurable via auth class
CSRFRequiredWS origin checkConfigurable (auth class)
BatchingNo (one call per fetch)NoNo
api_response / serialize= hooksNoN/AYes
Primary useIn-browser RPCReactive UIMobile / S2S / AI agents

Sub-path deploys (FORCE_SCRIPT_NAME, custom prefixes) <small>v0.7.1</small>

If your djust app is mounted under a URL prefix — either via Django's FORCE_SCRIPT_NAME setting or by passing a prefix= to api_patterns() — add {% djust_client_config %} to your base template's <head>:

{% load live_tags %}
<!DOCTYPE html>
<html>
<head>
    {% djust_client_config %}
    <!-- other head content -->
</head>

The tag emits <meta name="djust-api-prefix" content="...">. The content is resolved via Django's reverse(), so it honors FORCE_SCRIPT_NAME and any custom api_patterns(prefix=...) mount.

Three rules apply, each with an asserting test (Action Tracker #124):

  • Default-mounted deployment — the tag emits content="/djust/api/". (test_tag_emits_meta_with_default_prefix)
  • reverse() honors FORCE_SCRIPT_NAME — with FORCE_SCRIPT_NAME=/mysite, the tag emits content="/mysite/djust/api/". (test_tag_emits_meta_under_force_script_name)
  • Custom api_patterns(prefix=...) honored — mounting the API at a non-default prefix (e.g. api_patterns(prefix='myapi/')) emits content="/myapi/", and the client routes accordingly. (test_tag_emits_meta_when_api_mounted_at_custom_prefix)
  • Client falls back to /djust/api/ when no meta tag is present — if the tag is omitted or the API is not mounted, the client uses the compile-time default. (test_default_prefix_when_no_meta)
  • Explicit window.djust.apiPrefix takes priority over meta — an integrator who sets window.djust.apiPrefix = '/custom/' in a script loaded BEFORE client.js wins over whatever the meta tag says. (test_explicit_global_override_wins)

The client reads the meta once at bootstrap and exposes two new entries on the global namespace:

  • window.djust.apiPrefix — the resolved prefix string.
  • window.djust.apiUrl(path) — joins prefix + relative path with slash normalization. djust.call routes through this helper so sub-path deploys work out of the box.

When the tag is omitted, the client falls back to the compile-time default /djust/api/. That's the right choice for the default mount but will break any non-default deployment — add the tag to your base template before shipping.


Out of scope (future)

Things explicitly not in v0.7.0, tracked for later:

  • Batching. Bundling N calls into one round-trip.
  • SSE streaming responses. For long-running generators.
  • TypeScript bindings. Auto-generated .d.ts from Python signatures.
  • Client-side result cache. A djust.call wrapper that dedupes in-flight requests or caches results with a TTL.

If you hit a use case that needs one of these, open an issue — none are hard to add; they just weren't needed for v0.7.0's initial shape.